├── test ├── assets │ ├── home.coffee │ ├── invalid.coffee │ ├── js.coffee │ ├── ganma.css │ ├── hehe.css │ ├── home.less │ ├── home.styl │ ├── invalid.js │ ├── invalid.less │ ├── invalid.styl │ ├── hehe.js │ ├── ganma.js │ └── images │ │ └── test.jpg ├── views │ ├── subdir │ │ └── hoho.html │ └── hehe.html ├── transform.test.js └── builder.test.js ├── .eslintignore ├── .travis.yml ├── .gitignore ├── LICENSE ├── .eslintrc ├── package.json ├── lib ├── transform.js └── builder.js ├── bin └── builder.js └── README.md /test/assets/home.coffee: -------------------------------------------------------------------------------- 1 | foo = 1 2 | -------------------------------------------------------------------------------- /test/assets/invalid.coffee: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/assets/js.coffee: -------------------------------------------------------------------------------- 1 | square = (x) -> x * x 2 | -------------------------------------------------------------------------------- /test/assets/ganma.css: -------------------------------------------------------------------------------- 1 | .bar { 2 | float: left; 3 | } -------------------------------------------------------------------------------- /test/assets/hehe.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | float: left; 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.debug.js 2 | *.min.js 3 | node_modules/* 4 | -------------------------------------------------------------------------------- /test/assets/home.less: -------------------------------------------------------------------------------- 1 | .class { 2 | width: (1 + 1) 3 | } 4 | -------------------------------------------------------------------------------- /test/assets/home.styl: -------------------------------------------------------------------------------- 1 | .class { 2 | width: (1 + 1) 3 | } 4 | -------------------------------------------------------------------------------- /test/assets/invalid.js: -------------------------------------------------------------------------------- 1 | hehe 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/assets/invalid.less: -------------------------------------------------------------------------------- 1 | .class { 2 | width: (1 +) 3 | } 4 | -------------------------------------------------------------------------------- /test/assets/invalid.styl: -------------------------------------------------------------------------------- 1 | .class { 2 | width: (1 +) 3 | } 4 | -------------------------------------------------------------------------------- /test/assets/hehe.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const a = 'a'; 3 | console.log(a); 4 | }()); 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14" 4 | - "12" 5 | - "10" 6 | - "8" 7 | -------------------------------------------------------------------------------- /test/assets/ganma.js: -------------------------------------------------------------------------------- 1 | (function (b, c, d) { 2 | const a = 'a'; 3 | console.log(a); 4 | }()); 5 | -------------------------------------------------------------------------------- /test/assets/images/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JacksonTian/loader-builder/HEAD/test/assets/images/test.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | *.min.* 30 | *.debug.* 31 | *.*.jpg 32 | 33 | .nyc_output/ -------------------------------------------------------------------------------- /test/views/subdir/hoho.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- locals.title ? locals.title + ' - ' : '' %}消费者数据研究平台 4 | 5 | 8 | 9 | {%- Loader('/assets/styles/hoho.min.css') 10 | .css('/assets/styles/reset.css') 11 | .css('/assets/styles/common.css') 12 | .css('/assets/styles/site_nav.css') 13 | .css('/assets/styles/color.css') 14 | .css('/assets//styles/jquery.autocomplete.css') 15 | .done() 16 | %} 17 | {% if (locals.viewname) { %} 18 | 19 | {% } %} 20 | -------------------------------------------------------------------------------- /test/views/hehe.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | {%- locals.title ? locals.title + ' - ' : '' %}消费者数据研究平台 6 | 7 | 10 | 11 | {%- Loader('/assets/styles/common.min.css') 12 | .css('/assets/styles/reset.css') 13 | .css('/assets/styles/common.css') 14 | .css('/assets/styles/site_nav.css') 15 | .css('/assets/styles/color.css') 16 | .css('/assets//styles/jquery.autocomplete.css') 17 | .done() 18 | %} 19 | {% if (locals.viewname) { %} 20 | 21 | {% } %} 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jackson Tian 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 | 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [2, 2], 4 | "quotes": [2, "single"], 5 | "linebreak-style": [2, "unix"], 6 | "semi": [2, "always"], 7 | "strict": [2, "global"], 8 | "curly": 2, 9 | "eqeqeq": 2, 10 | "no-eval": 2, 11 | "guard-for-in": 2, 12 | "no-caller": 2, 13 | "no-else-return": 2, 14 | "no-eq-null": 2, 15 | "no-extend-native": 2, 16 | "no-extra-bind": 2, 17 | "no-floating-decimal": 2, 18 | "no-implied-eval": 2, 19 | "no-labels": 2, 20 | "no-with": 2, 21 | "no-loop-func": 1, 22 | "no-native-reassign": 2, 23 | "no-redeclare": [2, {"builtinGlobals": true}], 24 | "no-delete-var": 2, 25 | "no-shadow-restricted-names": 2, 26 | "no-undef-init": 2, 27 | "no-use-before-define": 2, 28 | "no-unused-vars": [2, {"args": "none"}], 29 | "no-undef": 2, 30 | "callback-return": [2, ["callback", "cb", "next"]], 31 | "global-require": 0, 32 | "no-console": 0 33 | }, 34 | "env": { 35 | "es6": true, 36 | "node": true, 37 | "browser": true 38 | }, 39 | "globals": { 40 | "describe": true, 41 | "it": true, 42 | "before": true, 43 | "after": true 44 | }, 45 | "extends": "eslint:recommended" 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loader-builder", 3 | "version": "2.7.2", 4 | "description": "Builder of Loader", 5 | "main": "lib/builder.js", 6 | "scripts": { 7 | "lint": "eslint --fix lib bin test", 8 | "test": "mocha -b -R spec test/*.test.js", 9 | "test-cov": "nyc -r=html -r=text -r=lcov mocha -R spec test/*.test.js", 10 | "ci": "npm run lint && npm run test-cov && codecov" 11 | }, 12 | "bin": { 13 | "loader": "bin/builder.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/JacksonTian/loader-builder.git" 18 | }, 19 | "keywords": [ 20 | "Loader", 21 | "Builder" 22 | ], 23 | "dependencies": { 24 | "babel-core": "^6.24.1", 25 | "babel-preset-es2015": "^6.24.1", 26 | "clean-css": "^4.1.3", 27 | "coffeescript": "^2.4.1", 28 | "colors": "^1.1.2", 29 | "kitx": "^1.0.0", 30 | "less": "^3.10.3", 31 | "stylus": "^0.54.5", 32 | "uglify-es": "^3.0.27" 33 | }, 34 | "devDependencies": { 35 | "coveralls": "*", 36 | "eslint": "^6.2.2", 37 | "expect.js": "^0.3.1", 38 | "nyc": "^14.1.1", 39 | "mocha": "^3.4.2", 40 | "mocha-lcov-reporter": "*", 41 | "should": "3.0.x", 42 | "travis-cov": "*" 43 | }, 44 | "author": "Jackson Tian", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/JacksonTian/loader-builder/issues" 48 | }, 49 | "homepage": "https://github.com/JacksonTian/loader-builder#readme", 50 | "files": [ 51 | "bin", 52 | "lib" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /lib/transform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const uglify = require('uglify-es'); 4 | const Cleaner = require('clean-css'); 5 | const less = require('less'); 6 | const stylus = require('stylus'); 7 | const coffee = require('coffeescript'); 8 | const babel = require('babel-core'); 9 | 10 | /** 11 | * 调用uglifyjs模块压缩脚本文件 12 | * @param {String} input JavaScript source code 13 | */ 14 | exports.transformScript = function (input) { 15 | var result = uglify.minify(input); 16 | if (result.error) {throw result.error;} 17 | return result.code; 18 | }; 19 | 20 | exports.transformEcmaScript = function (input) { 21 | var result = babel.transform(input, {presets: ['es2015']}); 22 | return result.code; 23 | }; 24 | 25 | /** 26 | * 调用clean css模块压缩样式表文件 27 | * @param {String} input CSS source code 28 | */ 29 | exports.transformStyle = function (input) { 30 | return new Cleaner().minify(input).styles; 31 | }; 32 | 33 | /** 34 | * 调用less模块编译less文件到CSS内容 35 | * @param {String} input JavaScript source code 36 | */ 37 | exports.transformLess = function (input, options) { 38 | var output; 39 | less.render(input, options, function (err, css) { 40 | if (err) { 41 | throw err; 42 | } 43 | output = css; 44 | }); 45 | return output.css; 46 | }; 47 | 48 | /** 49 | * 调用stylus模块编译stylus文件到CSS内容 50 | * @param {String} input JavaScript source code 51 | */ 52 | exports.transformStylus = function (input) { 53 | var output; 54 | stylus(input).render(function (err, css) { 55 | if (err) { 56 | throw err; 57 | } 58 | output = css; 59 | }); 60 | return output; 61 | }; 62 | 63 | /** 64 | * 调用coffee-script模块编译coffee文件到JS内容 65 | * @param {String} input JavaScript source code 66 | */ 67 | exports.transformCoffee = function (input) { 68 | return coffee.compile(input); 69 | }; 70 | -------------------------------------------------------------------------------- /bin/builder.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var path = require('path'); 5 | var fs = require('fs'); 6 | var builder = require('../lib/builder'); 7 | var colors = require('colors'); 8 | 9 | var argv = process.argv; 10 | 11 | if (argv.length < 4) { 12 | console.log('Example: loader views_dir base_dir [--no-debug]'); 13 | console.log('\tviews_dir: views folder'); 14 | console.log('\tbase_dir: project root folder'); 15 | console.log(''); 16 | console.log('e.g. loader ./views ./'); 17 | console.log('without debug file:'); 18 | console.log('e.g. loader ./views ./ --no-debug'); 19 | process.exit(1); 20 | } 21 | 22 | // app/views 23 | var viewsDir = path.join(process.cwd(), argv[2]); 24 | // app/ 25 | var baseDir = path.join(process.cwd(), argv[3]); 26 | // no debug 27 | var noDebug = argv[4] === '--no-debug'; 28 | 29 | var start = new Date(); 30 | // scan views folder, get the assets map 31 | var scaned = builder.scanDir(viewsDir); 32 | // check duplicate target 33 | builder.checkTarget(scaned); 34 | // console.log(scaned); 35 | console.log(colors.magenta('Scaned.'), colors.cyan(scaned.length), 36 | colors.magenta('asset(s) will be build.')); 37 | 38 | // combo?md5 hash 39 | var minified = builder.minify(baseDir, scaned, noDebug); 40 | // console.log(minified); 41 | console.log(colors.magenta(' 🏁 Compile static assets done.'), 42 | colors.gray('Build time'), colors.cyan(new Date() - start), 43 | colors.gray('ms.')); 44 | 45 | // write the assets mapping into assets.json 46 | var assets = path.join(baseDir, 'assets.json'); 47 | console.log(colors.magenta('assets.json is here: '), colors.cyan(assets)); 48 | var map = builder.map(minified); 49 | fs.writeFileSync(assets, JSON.stringify(map)); 50 | console.log(colors.magenta('write assets.json done. assets.json: ')); 51 | console.log(colors.gray(JSON.stringify(map, null, ' '))); 52 | -------------------------------------------------------------------------------- /test/transform.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var transform = require('../lib/transform'); 4 | var should = require('should'); 5 | 6 | describe('transform', function () { 7 | it('uglify/CSS should work well', function () { 8 | transform.transformScript('(function (a, b, c, d) {console.log(\'hello world!\');}());').should.equal('console.log("hello world!");'); 9 | transform.transformScript('var a = "a";var b = {[a]: "value"};').should.equal('var a="a",b={[a]:"value"};'); 10 | transform.transformStyle('.foo { float: left;}').should.equal('.foo{float:left}'); 11 | }); 12 | 13 | it('less should work well', function () { 14 | transform.transformLess('.class{width: (1 + 1)}').should.equal('.class {\n width: 2;\n}\n'); 15 | }); 16 | 17 | it('less should work with exception', function () { 18 | (function () { 19 | transform.transformLess('.class{width: (1 +)}'); 20 | }).should.throw('Expected \')\''); 21 | }); 22 | 23 | it('stylus should work well', function () { 24 | transform.transformStylus('.class{width: (1 + 1)}').should.equal('.class {\n width: 2;\n}\n'); 25 | }); 26 | 27 | it('stylus should work with exception', function () { 28 | (function () { 29 | transform.transformStylus('.class{width: (1 +)}'); 30 | }).should.throw('stylus:1:18\n 1| .class{width: (1 +)}\n-----------------------^\n\nCannot read property \'lineno\' of undefined\n at ".class" (stylus:2:1)\n'); 31 | }); 32 | 33 | it('coffee should work well', function () { 34 | transform.transformCoffee('foo = 1').should.equal('(function() {\n var foo;\n\n foo = 1;\n\n}).call(this);\n'); 35 | }); 36 | 37 | it('coffee should work with exception', function () { 38 | (function () { 39 | transform.transformCoffee(' = bar'); 40 | }).should.throw('missing 34 | 35 | 36 | ``` 37 | 38 | ## 构建 39 | 40 | 为了配合Loader的使用,builder需要通过构建的方式来生成静态文件的映射。其格式如下: 41 | 42 | ```json 43 | { 44 | "/assets/images/logo.png": "/assets/images/logo.b806e460.hashed.png", 45 | "/assets/scripts/bootstrap.js": "/assets/scripts/bootstrap.121539c7.min.js", 46 | "/assets/bootstrap-3.3.7/css/bootstrap.css": "/assets/bootstrap-3.3.7/css/bootstrap.b8e0f876.min.css" 47 | } 48 | ``` 49 | 50 | 如果需要线上执行,需要该对象的传入。生成方式为: 51 | 52 | ```sh 53 | $ builder 54 | $ # 或者 55 | $ npm install loader-builder --save 56 | $ ./node_modules/.bin/builder 57 | ``` 58 | 59 | 以上脚本将会遍历视图目录中寻找`Loader().js().css().done()`这样的标记,然后得到合并文件与实际文件的关系。如以上的`/assets/scripts/bootstrap.js`文件并不一定需要真正存在,进行扫描构建后,会将相关的`js`文件进行编译和合并为一个文件。并且根据文件内容进行md5取hash值,最终生成`/assets/scripts/bootstrap.121539c7.min.js`这样的文件。 60 | 61 | 遍历完目录后,将这些映射关系生成为`assets.json`文件,这个文件位于``指定的目录下。使用时请正确引入该文件,并借助服务端将其传递给`.done()`函数,作为assets参数。比如: 62 | 63 | ```js 64 | var assets = require('./assets.json'); 65 | // app.js 让assets变量在视图中可见 66 | this.state.assets = assets; 67 | // view.html 直接使用 68 | <%=Loader.file('/assets/images/logo.png').done(assets)%> 69 | // dev 70 | // => /assets/images/logo.png 71 | // production 72 | // => /assets/images/logo.b806e460.hashed.png 73 | ``` 74 | 75 | ## Support CDN 76 | 77 | 现在的CDN通常都具备自动回源功能,当配合CDN时,可以传入CDN前缀地址,作为`.done(assets, CDN)`的第二个参数。比如: 78 | 79 | ```js 80 | // app.js 让CDN变量在视图中可见 81 | this.state.CDN = 'http://cdn_domain'; 82 | // view.html 直接使用 83 | <%=Loader.file('/assets/images/logo.png').done(assets, CDN)%> 84 | // => http://cdn_domain/assets/images/logo.b806e460.hashed.png 85 | ``` 86 | 87 | 如果不使用CDN,传入空字符串即可,表示从当前服务器拉取文件。 88 | 89 | ## Support Debug 90 | 91 | loader-builder默认会帮助生成一个与编译合并后的文件相关的文件用于支持线上调试。比如`/assets/scripts/bootstrap.121539c7.min.js`对应的调试文件就是`/assets/scripts/bootstrap.121539c7.debug.js`。将`.min.`修改为`.debug.`即可。 92 | 93 | 通过debug文件,可以借助fiddler/anyproxy之类的HTTP请求转发工具进行对线上的代码调试。 94 | 95 | 通过添加`--no-debug`开关可以关闭debug文件的输出。如下所示: 96 | 97 | ```sh 98 | $ builder --no-debug 99 | ``` 100 | 101 | ## License 102 | The MIT license 103 | -------------------------------------------------------------------------------- /lib/builder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 仅在构建时使用,运行时无需引入 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const transform = require('./transform'); 7 | const colors = require('colors'); 8 | const kitx = require('kitx'); 9 | 10 | var md5 = function (content) { 11 | return kitx.md5(content, 'hex').slice(24); 12 | }; 13 | 14 | // Loader.file('/path/to/you/file.ext') 15 | // => 16 | // {target: '/path/to/your/file.ext', type: 'file'} 17 | exports.scanFile = function (view) { 18 | var patt = /Loader\.file\([\s\S]*?\)/gm; 19 | var argPatt = /Loader\.file\(['"]([^'"]+)['"][^)]*?\)/g; 20 | 21 | var retVal = []; 22 | 23 | var block; 24 | while ((block = patt.exec(view)) !== null) { 25 | var find = block[0]; 26 | if (find) { 27 | var arg; 28 | while ((arg = argPatt.exec(find)) !== null) { 29 | var src = arg[1]; 30 | retVal.push({target: src, type: 'file'}); 31 | } 32 | } 33 | } 34 | return retVal; 35 | }; 36 | 37 | /** 38 | * 扫描文本中的静态资源部分,提取出目标路径和文件列表。 39 | * 结果如下: 40 | * ``` 41 | * [ 42 | * {target: "x.js", assets:["path1", "path2"]}, 43 | * {target: "x.css", assets:["path1", "path2"]} 44 | * ] 45 | * ``` 46 | * @param {String} view view html code 47 | */ 48 | exports.scan = function (view) { 49 | var reg = /Loader\([\s\S]*?\.done\(.*\)/gm; 50 | var argReg = /Loader\(['"]([^'"]+)['"](?:,\s*['"]([^'"]+)['"])?\)/g; 51 | var jsReg = /.js\(['"](.*?)['"]\)/g; 52 | var cssReg = /.css\(['"](.*?)['"]\)/g; 53 | 54 | var retVal = []; 55 | 56 | var block; 57 | while ((block = reg.exec(view)) !== null) { 58 | var find = block[0]; 59 | if (find) { 60 | var arg; 61 | var target = {}; 62 | while ((arg = argReg.exec(find)) !== null) { 63 | target[path.extname(arg[1])] = arg[1]; 64 | if (arg[2]) { 65 | target[path.extname(arg[2])] = arg[2]; 66 | } 67 | } 68 | 69 | var jsAssets = []; 70 | var js; 71 | while ((js = jsReg.exec(find)) !== null) { 72 | jsAssets.push(js[1]); 73 | } 74 | if (jsAssets.length) { 75 | if (!target['.js']) { 76 | throw new Error('Must specfic key(.js) in block:\n' + block); 77 | } 78 | retVal.push({target: target['.js'], type: 'js', assets: jsAssets}); 79 | } 80 | 81 | var cssAssets = []; 82 | var css; 83 | while ((css = cssReg.exec(find)) !== null) { 84 | cssAssets.push(css[1]); 85 | } 86 | if (cssAssets.length) { 87 | if (!target['.css']) { 88 | throw new Error('Must specfic key(.css) in block:\n' + block); 89 | } 90 | retVal.push({target: target['.css'], type: 'css', assets: cssAssets}); 91 | } 92 | } 93 | } 94 | 95 | return retVal; 96 | }; 97 | 98 | /** 99 | * 根据传入映射关系数组和指定的基本目录地址,调用uglifyjs和cleancss压缩文本 100 | * 并生成带MD5签名的压缩文件,以及一个debug文件 101 | * ``` 102 | * [ 103 | * {target: "x.js", assets:["path1", "path2"]}, 104 | * {target: "x.css", assets:["path1", "path2"]} 105 | * ] 106 | * => 107 | * [ 108 | * {target: "x.js", min: "x.hash.js", debug: "x.hash.debug.js", 109 | * assets:["path1", "path2"]}, 110 | * {target: "x.css", min: "x.hash.css", debug: "x.hash.debug.css", 111 | * assets:["path1", "path2"]} 112 | * ] 113 | * ``` 114 | * @param {String} basedir 基本目录路径 115 | * @param {Array} arr 静态资源数组 116 | */ 117 | exports.minify = function (basedir, arr, noDebug) { 118 | var cache = {}; 119 | var fileCache = {}; 120 | arr.forEach(function (item, index) { 121 | var start = new Date(); 122 | console.log(colors.green('Building...'), colors.green(item.target)); 123 | var target = item.target; 124 | var extname = path.extname(target); 125 | var basename = path.basename(target, extname); 126 | var dirname = path.dirname(target); 127 | 128 | if (item.type === 'file') { 129 | process.stdout.write(colors.yellow('\tprocessing... ' + item.target)); 130 | if (!fileCache[item.target]) { 131 | var file = fs.readFileSync(path.join(basedir, item.target)); 132 | var hashed = md5(file); 133 | item.min = `${dirname}/${basename}.${hashed}.hashed${extname}`; 134 | fs.writeFileSync(path.join(basedir, item.min), file); 135 | fileCache[item.target] = item.min; 136 | process.stdout.write(' ..build'); 137 | } else { 138 | item.min = fileCache[item.target]; 139 | process.stdout.write(' ..cached'); 140 | } 141 | process.stdout.write(` .. use ${new Date() - start}ms.\n`); 142 | console.log(colors.green('✔ Done.'), colors.gray('Build time'), colors.cyan(new Date() - start), colors.gray('ms.')); 143 | return; 144 | } 145 | 146 | // combo 147 | var content = ''; 148 | var minified = ''; 149 | item.assets.forEach(function (asset) { 150 | var startAt = new Date(); 151 | process.stdout.write(colors.yellow('\tprocessing... ' + asset)); 152 | var cached = cache[asset]; 153 | // 编译,压缩 154 | if (!cached) { 155 | var file = path.join(basedir, asset); 156 | var text = fs.readFileSync(file, 'utf-8'); 157 | var extname = path.extname(file); 158 | if (extname === '.less') { 159 | text = transform.transformLess(text, {filename: file}); 160 | } else if (extname === '.styl') { 161 | text = transform.transformStylus(text); 162 | } else if (extname === '.coffee') { 163 | text = transform.transformCoffee(text); 164 | } else if (extname === '.es') { 165 | text = transform.transformEcmaScript(text); 166 | } 167 | var transformed; 168 | // transformed 169 | try { 170 | if (asset.endsWith('.min.js') || 171 | asset.endsWith('.min.css')) { 172 | transformed = text; 173 | process.stdout.write(' ..minified'); 174 | } else if (extname === '.js' || extname === '.coffee' || 175 | extname === '.es') { 176 | transformed = transform.transformScript(text); 177 | process.stdout.write(' ..build'); 178 | } else { 179 | // 压缩css之前,将url(img)中的图片进行优化、hash、替换 180 | var output = exports.processUrl(basedir, text, fileCache, item.target); 181 | transformed = transform.transformStyle(output); 182 | process.stdout.write(' ..build'); 183 | } 184 | } catch (ex) { 185 | var message = [ 186 | '\t✘ Error! File:' + asset, 187 | '\t\tLine: ' + ex.line, 188 | '\t\tCol: ' + ex.col 189 | ]; 190 | console.log(colors.red(message.join('\n'))); 191 | ex.message = `Compress ${asset} has error:\n` + ex.message; 192 | throw ex; 193 | } 194 | cache[asset] = { 195 | text: text + '\n', 196 | minified: transformed + '\n' 197 | }; 198 | cached = cache[asset]; 199 | } else { 200 | process.stdout.write(' ..cached'); 201 | } 202 | process.stdout.write(' .. use ' + (new Date() - startAt) + 'ms.\n'); 203 | 204 | minified += cached.minified; 205 | if (noDebug !== true) { 206 | // debug 207 | content += cached.text; 208 | } 209 | }); 210 | 211 | // add hash 212 | var hash = md5(minified); 213 | 214 | // 写入压缩的文件和debug版本的文件 215 | item.min = `${dirname}/${basename}.${hash}.min${extname}`; 216 | fs.writeFileSync(path.join(basedir, item.min), minified); 217 | if (noDebug !== true) { 218 | item.debug = `${dirname}/${basename}.${hash}.debug${extname}`; 219 | fs.writeFileSync(path.join(basedir, item.debug), content); 220 | } 221 | var end = new Date(); 222 | console.log(colors.green('✔ Done.'), colors.gray('Build time'), colors.cyan(end - start), colors.gray('ms.')); 223 | }); 224 | // clean cache 225 | cache = {}; 226 | return arr; 227 | }; 228 | 229 | /** 230 | * 将压缩生成的文件映射关系转换为map 231 | * ``` 232 | * [ 233 | * {target: "x.js", min: "x.hash.js", debug: "x.hash.debug.js", 234 | * assets:["path1", "path2"]}, 235 | * {target: "x.css", min: "x.hash.css", debug: "x.hash.debug.css", 236 | * assets:["path1", "path2"]} 237 | * ] 238 | * => 239 | * { 240 | * "x.js": "x.hash.js", 241 | * "x.css": "x.hash.css" 242 | * } 243 | * ``` 244 | * @param {Array} arr 压缩生成的映射关系数组 245 | */ 246 | exports.map = function (arr) { 247 | var map = {}; 248 | arr.forEach(function (item) { 249 | map[item.target] = item.min; 250 | }); 251 | return map; 252 | }; 253 | 254 | /** 255 | * 扫描指定目录,生成合并压缩映射关系数组 256 | * 生成结构如下: 257 | * ``` 258 | * [ 259 | * {target: "x.js", assets:["path1", "path2"]}, 260 | * {target: "x.css", assets:["path1", "path2"]} 261 | * ] 262 | * ``` 263 | * @param {String} dirpath The dir path 264 | */ 265 | exports.scanDir = function (dirpath) { 266 | var views = fs.readdirSync(dirpath).sort(); 267 | var combo = []; 268 | 269 | views = views.filter(function (val, index) { 270 | return ['.DS_Store', '.svn', '.git'].indexOf(val) === -1; 271 | }); 272 | 273 | views.forEach(function (filename, index) { 274 | var realPath = path.join(dirpath, filename); 275 | var stat = fs.statSync(realPath); 276 | if (stat.isFile()) { 277 | var section = fs.readFileSync(realPath, 'utf8'); 278 | combo = combo.concat(exports.scan(section)); 279 | combo = combo.concat(exports.scanFile(section)); 280 | } else if (stat.isDirectory()) { 281 | combo = combo.concat(exports.scanDir(realPath)); 282 | } 283 | }); 284 | 285 | return combo; 286 | }; 287 | 288 | exports.checkTarget = function (scaned) { 289 | var targets = {}; 290 | scaned.forEach(function (item) { 291 | // Loader.file()可以重复 292 | if (item.type !== 'file' && Object.prototype.hasOwnProperty.call(targets, item.target)) { 293 | console.warn('Duplicate target: ' + item.target); 294 | } 295 | targets[item.target] = true; 296 | }); 297 | }; 298 | 299 | // url(xxx); 300 | const reg = /(url\(['"]?)([^?)'"#]*)(.*['"]?\))/g; 301 | exports.processUrl = function (basedir, text, fileCache, to) { 302 | return text.replace(reg, function (match, $1, $2, $3) { 303 | var target = $2; 304 | 305 | // ignore uri & data-uri 306 | if (target.startsWith('http://') || 307 | target.startsWith('https://') || 308 | target.startsWith('data:')) { 309 | return $1 + target + $3; 310 | } 311 | 312 | // 特殊文件路径,不要处理 313 | if (match.indexOf('?process=no') !== -1) { 314 | return $1 + target + $3; 315 | } 316 | 317 | // convert relative path to absolute path 318 | if (!target.startsWith('/')) { 319 | target = path.resolve(path.dirname(to), target); 320 | } 321 | 322 | var cache = fileCache[target]; 323 | if (cache) { 324 | return $1 + cache + $3; 325 | } 326 | 327 | var extname = path.extname(target); 328 | var basename = path.basename(target, extname); 329 | var dirname = path.dirname(target); 330 | 331 | var file = fs.readFileSync(path.join(basedir, target)); 332 | var hashed = dirname + '/' + basename + '.' + md5(file) + '.hashed' + extname; 333 | fs.writeFileSync(path.join(basedir, hashed), file); 334 | fileCache[target] = hashed; 335 | return $1 + hashed + $3; 336 | }); 337 | }; 338 | -------------------------------------------------------------------------------- /test/builder.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var fs = require('fs'); 5 | var should = require('should'); 6 | var expect = require('expect.js'); 7 | var builder = require('../lib/builder'); 8 | 9 | describe('builder', function () { 10 | it('scanView', function () { 11 | var str = '' + 12 | '\n' + 13 | ' \n' + 14 | ' \n' + 15 | ' {%- partial(\'head.inc.html\') %}\n' + 16 | ' \n' + 17 | ' {%- Loader("/assets/scripts/index.min.js")\n' + 18 | ' .js("/assets/scripts/index.js")\n' + 19 | ' .done(version) %}\n' + 20 | ' {%- Loader("/assets/scripts/jqueryplugin.min.js", "/assets/styles/jqueryplugin.min.css")\n' + 21 | ' .js("/assets/scripts/lib/jquery.jmodal.js")\n' + 22 | ' .js("/assets/scripts/lib/jquery.mousewheel.min.js")\n' + 23 | ' .js("/assets/scripts/lib/jquery.tagsphere.min.js")\n' + 24 | ' .css("/hehe")\n' + 25 | ' .done() %}\n' + 26 | ''; 27 | 28 | builder.scan(str).should.eql([ 29 | { 30 | target: '/assets/scripts/index.min.js', 31 | assets: [ '/assets/scripts/index.js' ], 32 | type: 'js' 33 | }, 34 | { 35 | target: '/assets/scripts/jqueryplugin.min.js', 36 | assets: [ '/assets/scripts/lib/jquery.jmodal.js', 37 | '/assets/scripts/lib/jquery.mousewheel.min.js', 38 | '/assets/scripts/lib/jquery.tagsphere.min.js' ], 39 | type: 'js' 40 | }, 41 | { target: '/assets/styles/jqueryplugin.min.css', 42 | assets: [ '/hehe' ], 43 | type: 'css' 44 | } 45 | ]); 46 | 47 | builder.scanDir(path.join(__dirname, 'views')).should.eql([ 48 | { target: '/assets/styles/common.min.css', 49 | 'type': 'css', 50 | assets: [ 51 | '/assets/styles/reset.css', 52 | '/assets/styles/common.css', 53 | '/assets/styles/site_nav.css', 54 | '/assets/styles/color.css', 55 | '/assets//styles/jquery.autocomplete.css' 56 | ] 57 | }, 58 | { 59 | target: '/assets/images/test.jpg', 60 | 'type': 'file' 61 | }, 62 | { target: '/assets/styles/hoho.min.css', 63 | 'type': 'css', 64 | assets: 65 | [ '/assets/styles/reset.css', 66 | '/assets/styles/common.css', 67 | '/assets/styles/site_nav.css', 68 | '/assets/styles/color.css', 69 | '/assets//styles/jquery.autocomplete.css' ] 70 | } 71 | ]); 72 | }); 73 | 74 | it('scanView with empty list', function () { 75 | var str = '{%- Loader("/assets/styles/common.min.css", "/assets/js/js.min.js")\n' + 76 | ' .css("/assets/styles/reset.css")\n' + 77 | ' .css("/assets/styles/common.css")\n' + 78 | ' .css("/assets/styles/site_nav.css")\n' + 79 | ' .css("/assets/styles/color.css")\n' + 80 | ' .css("/assets//styles/jquery.autocomplete.css")\n' + 81 | ' .done()\n' + 82 | '%}'; 83 | 84 | builder.scan(str).should.eql([ 85 | { 86 | target: '/assets/styles/common.min.css', 87 | type: 'css', 88 | assets: 89 | [ '/assets/styles/reset.css', 90 | '/assets/styles/common.css', 91 | '/assets/styles/site_nav.css', 92 | '/assets/styles/color.css', 93 | '/assets//styles/jquery.autocomplete.css' ] 94 | } 95 | ]); 96 | }); 97 | 98 | it('scanView with mutiple files in one line', function () { 99 | var str = '{%- Loader("/assets/styles/common.min.css", "/assets/js/js.min.js")' + 100 | '.css("/assets/styles/reset.css")' + 101 | '.css("/assets/styles/common.css")' + 102 | '.css("/assets/styles/site_nav.css")' + 103 | '.css("/assets/styles/color.css")' + 104 | '.css("/assets//styles/jquery.autocomplete.css")' + 105 | '.done()' + 106 | '%}'; 107 | 108 | builder.scan(str).should.eql([ 109 | { 110 | target: '/assets/styles/common.min.css', 111 | type: 'css', 112 | assets: 113 | [ '/assets/styles/reset.css', 114 | '/assets/styles/common.css', 115 | '/assets/styles/site_nav.css', 116 | '/assets/styles/color.css', 117 | '/assets//styles/jquery.autocomplete.css' ] 118 | } 119 | ]); 120 | }); 121 | 122 | it('minify should work well', function () { 123 | var arr = [ 124 | {'target': '/assets/min.js', type: 'js', 'assets': ['/assets/hehe.js', '/assets/ganma.js']}, 125 | {'target': '/assets/min.css', type: 'css', 'assets': ['/assets/hehe.css', '/assets/ganma.css', '/assets/home.less']} 126 | ]; 127 | var minified = builder.minify(__dirname, arr); 128 | minified.should.eql([ 129 | { target: '/assets/min.js', 130 | 'type': 'js', 131 | assets: [ '/assets/hehe.js', '/assets/ganma.js' ], 132 | min: '/assets/min.c259e248.min.js', 133 | debug: '/assets/min.c259e248.debug.js' 134 | }, 135 | { target: '/assets/min.css', 136 | 'type': 'css', 137 | assets: [ '/assets/hehe.css', '/assets/ganma.css', '/assets/home.less' ], 138 | min: '/assets/min.0d525130.min.css', 139 | debug: '/assets/min.0d525130.debug.css' 140 | } 141 | ]); 142 | 143 | var map = builder.map(minified); 144 | var minJS = path.join(__dirname, map['/assets/min.js']); 145 | var minCSS = path.join(__dirname, map['/assets/min.css']); 146 | 147 | fs.readFileSync(minJS, 'utf-8').should.equal('console.log("a");\nconsole.log("a");\n'); 148 | fs.readFileSync(minCSS, 'utf-8').should.equal('.foo{float:left}\n.bar{float:left}\n.class{width:2}\n'); 149 | }); 150 | 151 | it('minify should ok with no debug', function () { 152 | var arr = [ 153 | {'target': '/assets/no-debug.js', type: 'js', 'assets': ['/assets/hehe.js', '/assets/ganma.js']}, 154 | {'target': '/assets/no-debug.css', type: 'css', 'assets': ['/assets/hehe.css', '/assets/ganma.css', '/assets/home.less']} 155 | ]; 156 | var minified = builder.minify(__dirname, arr, true); 157 | minified.should.eql([ 158 | { target: '/assets/no-debug.js', 159 | 'type': 'js', 160 | assets: [ '/assets/hehe.js', '/assets/ganma.js' ], 161 | min: '/assets/no-debug.c259e248.min.js', 162 | //debug: '/assets/min.7d0550f0.debug.js' 163 | }, 164 | { target: '/assets/no-debug.css', 165 | 'type': 'css', 166 | assets: [ '/assets/hehe.css', '/assets/ganma.css', '/assets/home.less' ], 167 | min: '/assets/no-debug.0d525130.min.css', 168 | // debug: '/assets/min.0d525130.debug.css' 169 | } 170 | ]); 171 | 172 | var map = builder.map(minified); 173 | var minJS = path.join(__dirname, map['/assets/no-debug.js']); 174 | var minCSS = path.join(__dirname, map['/assets/no-debug.css']); 175 | var debugJS = minJS.replace('.min.', '.debug.'); 176 | var debugCSS = minCSS.replace('.min.', '.debug.'); 177 | expect(fs.existsSync(debugJS)).to.be(false); 178 | expect(fs.existsSync(debugCSS)).to.be(false); 179 | }); 180 | 181 | it('minify should work well with coffee', function () { 182 | var arr = [ 183 | {'target': '/assets/coffee.js', 'assets': ['/assets/js.coffee']} 184 | ]; 185 | var minified = builder.minify(__dirname, arr); 186 | minified.should.eql([ 187 | { target: '/assets/coffee.js', 188 | assets: [ '/assets/js.coffee'], 189 | min: '/assets/coffee.c8f72dd9.min.js', 190 | debug: '/assets/coffee.c8f72dd9.debug.js' 191 | } 192 | ]); 193 | 194 | var map = builder.map(minified); 195 | var minJS = path.join(__dirname, map['/assets/coffee.js']); 196 | 197 | fs.readFileSync(minJS, 'utf-8').should.equal('(function(){}).call(this);\n'); 198 | }); 199 | 200 | it('minify should work well with coffee', function () { 201 | var arr = [ 202 | {'target': '/assets/coffee.js', 'assets': ['/assets/js.coffee']} 203 | ]; 204 | var minified = builder.minify(__dirname, arr); 205 | minified.should.eql([ 206 | { target: '/assets/coffee.js', 207 | assets: [ '/assets/js.coffee'], 208 | min: '/assets/coffee.c8f72dd9.min.js', 209 | debug: '/assets/coffee.c8f72dd9.debug.js' 210 | } 211 | ]); 212 | 213 | var map = builder.map(minified); 214 | var minJS = path.join(__dirname, map['/assets/coffee.js']); 215 | 216 | fs.readFileSync(minJS, 'utf-8').should.equal('(function(){}).call(this);\n'); 217 | }); 218 | 219 | it('minify should work well with stylus', function () { 220 | var arr = [ 221 | {'target': '/assets/home.css', 'assets': ['/assets/home.styl']} 222 | ]; 223 | var minified = builder.minify(__dirname, arr); 224 | minified.should.eql([ 225 | { target: '/assets/home.css', 226 | assets: [ '/assets/home.styl'], 227 | min: '/assets/home.cb2d2217.min.css', 228 | debug: '/assets/home.cb2d2217.debug.css' 229 | } 230 | ]); 231 | 232 | var map = builder.map(minified); 233 | var minJS = path.join(__dirname, map['/assets/home.css']); 234 | 235 | fs.readFileSync(minJS, 'utf-8').should.equal('.class{width:2}\n'); 236 | }); 237 | 238 | it('minify should work well with file', function () { 239 | var arr = [ 240 | {'target': '/assets/images/test.jpg', type: 'file'} 241 | ]; 242 | var minified = builder.minify(__dirname, arr); 243 | minified.should.eql([ 244 | { target: '/assets/images/test.jpg', 245 | min: '/assets/images/test.43e9fc4d.hashed.jpg', 246 | 'type': 'file' 247 | } 248 | ]); 249 | 250 | var map = builder.map(minified); 251 | map.should.eql({ 252 | '/assets/images/test.jpg': '/assets/images/test.43e9fc4d.hashed.jpg' 253 | }); 254 | var file = path.join(__dirname, map['/assets/images/test.jpg']); 255 | fs.readFileSync(file).should.be.ok; 256 | }); 257 | 258 | it('minify should work with exception', function () { 259 | var arr = [ 260 | {'target': '/assets/sorry.js', 'assets': ['/assets/invalid.js']} 261 | ]; 262 | (function () { 263 | builder.minify(__dirname, arr); 264 | }).should.throw('Compress /assets/invalid.js has error:\nUnexpected token: operator (<)'); 265 | }); 266 | 267 | it('processUrl should ok', function () { 268 | var input = 'background-image: url(\'/assets/images/test.jpg\');'; 269 | var output = builder.processUrl(__dirname, input, {}); 270 | output.should.be.equal('background-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg\');'); 271 | }); 272 | 273 | it('processUrl with hash should ok', function () { 274 | var input = 'background-image: url(\'/assets/images/test.jpg#hash\');'; 275 | var output = builder.processUrl(__dirname, input, {}); 276 | output.should.be.equal('background-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg#hash\');'); 277 | }); 278 | 279 | it('processUrl hit cache should ok', function () { 280 | var input = 'background-image: url(\'/assets/images/test.jpg#hash\');\nbackground-image: url(\'/assets/images/test.jpg\');'; 281 | var output = builder.processUrl(__dirname, input, {}); 282 | output.should.be.equal('background-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg#hash\');\nbackground-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg\');'); 283 | }); 284 | 285 | it('processUrl with http(x):// should ok', function () { 286 | var input = 'background-image: url(\'http://domain.com/assets/images/test.jpg\');'; 287 | var output = builder.processUrl(__dirname, input, {}); 288 | output.should.be.equal('background-image: url(\'http://domain.com/assets/images/test.jpg\');'); 289 | }); 290 | 291 | it('processUrl with data:uri should ok', function () { 292 | var input = 'background-image: url(\'data:,Hello%2C%20World!\');'; 293 | var output = builder.processUrl(__dirname, input, {}); 294 | output.should.be.equal('background-image: url(\'data:,Hello%2C%20World!\');'); 295 | }); 296 | 297 | it('processUrl with (..) should ok', function () { 298 | var input = 'background-image: url(\'../images/test.jpg\');'; 299 | var output = builder.processUrl(__dirname, input, {}, '/assets/styles/main.css'); 300 | output.should.be.equal('background-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg\');'); 301 | }); 302 | 303 | it('processUrl with (.) should ok', function () { 304 | var input = 'background-image: url(\'./images/test.jpg\');'; 305 | var output = builder.processUrl(__dirname, input, {}, '/assets/main.css'); 306 | output.should.be.equal('background-image: url(\'/assets/images/test.43e9fc4d.hashed.jpg\');'); 307 | }); 308 | }); 309 | --------------------------------------------------------------------------------