├── .npmignore ├── src ├── bin │ ├── .gitkeep │ ├── pack.js │ ├── watch.js │ ├── wildcard.js │ ├── rev.js │ └── usemin.js └── lib │ ├── globpromise.js │ ├── storage.js │ ├── compressor.js │ └── xfs.js ├── bin ├── .npmignore └── .gitignore ├── lib ├── .npmignore └── .gitignore ├── tests ├── src │ ├── test4-1.js │ ├── test.css │ ├── test7-1.js │ ├── test7-1.css │ ├── test8-1.css │ ├── webspoon.html │ ├── test4-2.js │ ├── test7-2.css │ ├── test8-2.css │ ├── test7-2.js │ ├── test8-2.js │ ├── test8-1.js │ ├── test5.html │ ├── test7.html │ ├── test.js │ ├── test2.html │ ├── test1.html │ ├── test4.html │ ├── test6.html │ ├── test3.html │ ├── test9.html │ ├── test5.js │ └── test8.html ├── .gitignore ├── Makefile └── index.html ├── .gitignore ├── .babelrc ├── LICENSE.txt ├── Makefile ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/bin/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/.npmignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/.npmignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/src/test4-1.js: -------------------------------------------------------------------------------- 1 | // -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules 3 | -------------------------------------------------------------------------------- /bin/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.*ignore 3 | -------------------------------------------------------------------------------- /lib/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.*ignore 3 | -------------------------------------------------------------------------------- /tests/src/test.css: -------------------------------------------------------------------------------- 1 | * { color: red; } 2 | -------------------------------------------------------------------------------- /tests/src/test7-1.js: -------------------------------------------------------------------------------- 1 | var str = 'WebSpoon'; 2 | -------------------------------------------------------------------------------- /tests/src/test7-1.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /tests/src/test8-1.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0" ] 3 | } 4 | -------------------------------------------------------------------------------- /tests/src/webspoon.html: -------------------------------------------------------------------------------- 1 |

WebSpoon

2 | -------------------------------------------------------------------------------- /tests/src/test4-2.js: -------------------------------------------------------------------------------- 1 | document.write('

WebSpoon

'); 2 | -------------------------------------------------------------------------------- /tests/src/test7-2.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 2em; 3 | font-weight: bold; 4 | } 5 | -------------------------------------------------------------------------------- /tests/src/test8-2.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 2em; 3 | font-weight: bold; 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | Copyright 2015 YanagiEiichi, yanagieiichi@web-tinker.com 3 | 4 | -------------------------------------------------------------------------------- /tests/src/test7-2.js: -------------------------------------------------------------------------------- 1 | onload = function() { 2 | document.body.innerHTML = str; 3 | }; 4 | -------------------------------------------------------------------------------- /tests/src/test8-2.js: -------------------------------------------------------------------------------- 1 | (onload = function() { 2 | document.body.textContent = getStr(); 3 | }) 4 | -------------------------------------------------------------------------------- /tests/src/test8-1.js: -------------------------------------------------------------------------------- 1 | // Test no tailing semicolon & newline 2 | var getStr = function() { return 'WebSpoon' } -------------------------------------------------------------------------------- /tests/src/test5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/src/test7.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/src/test.js: -------------------------------------------------------------------------------- 1 | onload = function() { 2 | var h1 = document.createElement('h1'); 3 | h1.textContent = 'WebSpoon'; 4 | document.body.appendChild(h1); 5 | } 6 | -------------------------------------------------------------------------------- /tests/src/test2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/globpromise.js: -------------------------------------------------------------------------------- 1 | import glob from 'glob'; 2 | export default fileName => new Promise((resolve, reject) => { 3 | glob(fileName, { follow: false }, (error, list) => error ? reject(error) : resolve(list)); 4 | }); 5 | -------------------------------------------------------------------------------- /tests/src/test1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/src/test4.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/src/test6.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /tests/src/test3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /tests/src/test9.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /tests/src/test5.js: -------------------------------------------------------------------------------- 1 | if(!document.currentScript.async) throw 'usemin 没有处理 async'; 2 | onload = function() { 3 | if(/^dist\/dist5\.js$/.test('dist/dist5.js')) throw 'rev 没有成功'; 4 | var h1 = document.createElement('h1'); 5 | h1.textContent = 'WebSpoon'; 6 | h1.style.color = 'red'; 7 | document.body.appendChild(h1); 8 | } 9 | -------------------------------------------------------------------------------- /tests/src/test8.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | default: update build check 2 | 3 | update: 4 | @cd .. && make build 5 | 6 | build: clean copy pack wildcard usemin rev 7 | 8 | clean: 9 | @rm -rf dist 10 | 11 | copy: 12 | @mkdir -p dist 13 | @cp -r src/* dist 14 | 15 | usemin: 16 | @../bin/usemin.js dist/*.html 17 | 18 | pack: 19 | @../bin/pack.js 'src/**/webspoon.html' -regexp '^src' -replacement 'xxx' > dist/webspoon.html.js 20 | 21 | wildcard: 22 | @../bin/wildcard.js 'dist/**/test7.html' 23 | 24 | rev: 25 | @../bin/rev.js -base 'dist/test5.html' -base 'dist/dist5.js' -static 'dist/dist5.js' 26 | 27 | check: 28 | @open index.html 29 | -------------------------------------------------------------------------------- /src/lib/storage.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import denodeify from 'denodeify'; 3 | import mkdirp from 'mkdirp'; 4 | import path from 'path'; 5 | const readFile = denodeify(fs.readFile); 6 | const writeFile = denodeify(fs.writeFile); 7 | const mkdirx = denodeify(mkdirp); 8 | 9 | export default class Storage { 10 | constructor(dirname = '/tmp') { 11 | this.dirname = mkdirx(dirname).then(() => dirname, () => dirname); 12 | } 13 | get(name) { 14 | return this.dirname.then(dirname => readFile(path.join(dirname, name)).then(String)); 15 | } 16 | set(name, data) { 17 | return this.dirname.then(dirname => writeFile(path.join(dirname, name), data)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 26 | 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "# help" 3 | @echo "$$ make # help" 4 | @echo "$$ make clean # clear bin/*.js" 5 | @echo "$$ make build # build src to bin" 6 | @echo "$$ make test # run test scripts" 7 | @echo "$$ make watch # watch and build" 8 | @echo "" 9 | 10 | test: 11 | @cd tests && make 12 | 13 | build: $(shell find src -name '*.js' | sed 's/^src\///') 14 | @# build done 15 | 16 | babel := ./node_modules/.bin/babel 17 | 18 | bin/%.js: src/bin/%.js 19 | @echo "Build $< to $@ ... \c" 20 | @$(babel) $< > $@ 21 | @chmod 755 $@ 22 | @echo "OK" 23 | 24 | lib/%.js: src/lib/%.js 25 | @echo "Build $< to $@ ... \c" 26 | @$(babel) $< > $@ 27 | @chmod 644 $@ 28 | @echo "OK" 29 | 30 | clean: 31 | @rm -rf bin/* 32 | 33 | watch: 34 | @./bin/watch.js -target 'src' \ 35 | -exec "[ \$$(echo \$$src | grep -v '/\\.' | wc -l) -gt 0 ] || exit" \ 36 | -exec 'make build' 37 | -------------------------------------------------------------------------------- /src/lib/compressor.js: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import UglifyJS from 'uglify-js'; 5 | import CleanCss from 'clean-css'; 6 | import Storage from './storage'; 7 | import crypto from 'crypto'; 8 | import argollector from 'argollector'; 9 | 10 | const cache = new Storage(argollector['--usemin-cache'] || path.join(process.env.HOME, '.webspoon')); 11 | 12 | const compressWithoutCache = (type, code, hash) => { 13 | let result; 14 | switch(type) { 15 | case 'js': 16 | result = UglifyJS.minify(code, { fromString: true }).code; 17 | break; 18 | case 'css': 19 | result = new CleanCss().minify(code).styles; 20 | break; 21 | default: 22 | throw new Error(`Unknown type (${type}) to compress`); 23 | } 24 | if (type !== void 0) return cache.set(hash, result).then(() => result, () => result); 25 | }; 26 | 27 | const compress = (type, code) => { 28 | let hash = crypto.createHash('sha1').update(type + code).digest('hex'); 29 | return cache.get(hash).catch(() => compressWithoutCache(type, code, hash)); 30 | }; 31 | 32 | export default { 33 | js: compress.bind(null, 'js'), 34 | css: compress.bind(null, 'css') 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webspoon", 3 | "version": "1.4.3", 4 | "description": "这是一个 Web 前端工程化的工具包。", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "prepublish": "make build", 11 | "test": "make test" 12 | }, 13 | "bin": { 14 | "webspoon-usemin": "./bin/usemin.js", 15 | "webspoon-rev": "./bin/rev.js", 16 | "webspoon-watch": "./bin/watch.js", 17 | "webspoon-pack": "./bin/pack.js", 18 | "webspoon-wildcard": "./bin/wildcard.js" 19 | }, 20 | "author": "YanagiEiichi", 21 | "license": "MIT", 22 | "dependencies": { 23 | "argollector": "^1.0.0", 24 | "babel-fs": "^1.0.2", 25 | "capacitance": "^1.0.0", 26 | "clean-css": "^3.4.8", 27 | "denodeify": "^1.2.1", 28 | "glob": "^5.0.14", 29 | "mkdirp": "^0.5.1", 30 | "uglify-js": "^2.6.1" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.11.4", 34 | "babel-preset-es2015": "^6.9.0", 35 | "babel-preset-stage-0": "^6.5.0" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+ssh://git@github.com/ElemeFE/webspoon.git" 40 | }, 41 | "keywords": [ 42 | "usemin", 43 | "rev" 44 | ], 45 | "bugs": { 46 | "url": "https://github.com/ElemeFE/webspoon/issues" 47 | }, 48 | "homepage": "https://github.com/ElemeFE/webspoon#readme" 49 | } 50 | -------------------------------------------------------------------------------- /src/bin/pack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import bfs from 'babel-fs'; 4 | import argollector from 'argollector'; 5 | 6 | import globPromise from '../lib/globpromise'; 7 | 8 | /** 9 | * 主过程 10 | **/ 11 | 12 | var regexp = new RegExp(argollector['-regexp'] && argollector['-regexp'][0] || '^'); 13 | var replacement = argollector['-replacement'] && argollector['-replacement'][0] || ''; 14 | var moduleName = argollector['-moduleName'] && argollector['-moduleName'][0] || 'templates'; 15 | 16 | // 这坨代码我自己都觉得烂,然而有更好的写法么? 17 | // 目的是对文件列表做一个排序,并且让同级的文件总是优先于目录 18 | var sortFileList = list => { 19 | for(let i = 0; i < list.length; i++) list[i] = list[i].split(/\//g); 20 | list.sort((a, b) => { 21 | let length = Math.max(a.length, b.length); 22 | for(let i = 0; i < length; i++) { 23 | let fileFirst = !!a[i + 1] - !!b[i + 1]; 24 | if(fileFirst) return fileFirst; 25 | let diff = (a[i] || '').localeCompare(b[i] || ''); 26 | if(diff) return diff; 27 | } 28 | }); 29 | for(let i = 0; i < list.length; i++) list[i] = list[i].join('/'); 30 | }; 31 | 32 | Promise 33 | // 组织参数,处理通配符 34 | .all(argollector.slice(0).concat(argollector['-files'] || []).map(globPromise)) 35 | .then(list => [].concat(...list)) 36 | // 读文件 37 | .then(list => { 38 | sortFileList(list); 39 | var result = {}; 40 | return Promise.all(list.map(path => { 41 | var key = path.replace(regexp, replacement); 42 | return bfs.readFile(path).then(data => result[key] = data + ''); 43 | })).then(() => result); 44 | }) 45 | // 输出 46 | .then(data => { 47 | process.stdout.write(` 48 | void function(moduleName, result) { 49 | switch(true) { 50 | // CommonJS 51 | case typeof module === 'object' && !!module.exports: 52 | module.exports = result; 53 | break; 54 | // AMD (Add a 'String' wrapper here to fuck webpack) 55 | case String(typeof define) === 'function' && !!define.amd: 56 | define(moduleName, function() { return result; }); 57 | break; 58 | // Global 59 | default: 60 | /**/ try { /* Fuck IE8- */ 61 | /**/ if(typeof execScript === 'object') execScript('var ' + moduleName); 62 | /**/ } catch(error) {} 63 | window[moduleName] = result; 64 | } 65 | }(${JSON.stringify(moduleName)}, ${JSON.stringify(data, null, 2)}); 66 | `.replace(/^\s*|\s*$/g, '') + '\n'); 67 | }); 68 | -------------------------------------------------------------------------------- /src/lib/xfs.js: -------------------------------------------------------------------------------- 1 | import argollector from 'argollector'; 2 | import crypto from 'crypto'; 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | 6 | const PLATFORM = os.platform(); 7 | 8 | const PROC_NAME = process.argv[1].replace(/.*?(\w)(\w*)\.js$/, ($0, $1, $2) => { 9 | let name = $1.toUpperCase() + $2; 10 | if (PLATFORM === 'darwin') name = `${name}`; 11 | return name; 12 | }); 13 | 14 | const log = message => { 15 | if (PLATFORM === 'darwin') { 16 | message = message.replace(/<.*?>/g, '$&'); 17 | message = message.replace(/'.*?'/g, '$&'); 18 | } 19 | console.log(`${PROC_NAME} ${message}`); 20 | }; 21 | 22 | const promiseifySync = f => { 23 | return (...args) => new Promise((resolve, reject) => { 24 | try { 25 | resolve(f(...args)); 26 | } catch (error) { 27 | reject(error); 28 | } 29 | }); 30 | }; 31 | 32 | // node 自带的异步文件操作没有锁,直接使用会出问题 33 | // 除非自己实现锁,否则不要使用异步文件操作 34 | const _readFile = promiseifySync(fs.readFileSync); 35 | const _writeFile = promiseifySync(fs.writeFileSync); 36 | const _rename = promiseifySync(fs.renameSync); 37 | const _stat = promiseifySync(fs.statSync); 38 | 39 | class FileInfo { 40 | static create(path) { 41 | return Promise.all([ _readFile(path), _stat(path) ]).then(([ buffer, stats ]) => new this(buffer, stats)); 42 | } 43 | constructor(buffer, stats) { 44 | this.buffer = buffer; 45 | this.stats = stats; 46 | } 47 | toString() { 48 | let hash = crypto.createHash('md5').update(this.buffer).digest('hex').slice(0, 6); 49 | let { length } = this.buffer; 50 | let mode = this.stats.mode.toString(8); 51 | return `<${hash}-${length}-${mode}>`; 52 | } 53 | } 54 | 55 | export var readFile = path => { 56 | return FileInfo.create(path).then(info => { 57 | log(`readFile('${path}') results ${info}`); 58 | return info.buffer; 59 | }); 60 | }; 61 | 62 | export var writeFile = (path, buffer) => { 63 | buffer = new Buffer(buffer); 64 | return _writeFile(path, buffer).then(() => FileInfo.create(path)).then(info => { 65 | log(`writeFile('${path}', ${info})`); 66 | }); 67 | }; 68 | 69 | export var rename = (oldPath, newPath) => { 70 | return FileInfo.create(oldPath).then(oldInfo => { 71 | return _rename(oldPath, newPath).then(() => { 72 | return FileInfo.create(newPath).then(newInfo => { 73 | log(`rename('${oldPath}', '${newPath}') move ${oldInfo} to ${newInfo}`); 74 | }); 75 | }); 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## WebSpoon 2 | 3 | 这是一个 Web 前端工程化的工具包。 4 | 5 | 6 | ### 安装方式 7 | 8 | ```bash 9 | npm install webspoon 10 | ``` 11 | 12 | 或者也可以全局安装 13 | 14 | ```bash 15 | sudo npm install webspoon -g 16 | ``` 17 | 18 | 19 | ### 提供的工具 20 | 21 | ##### webspoon-usemin 22 | 23 | 用于合并压缩 html 文件中特殊注释块内的静态资源。 24 | 25 | ```bash 26 | webspoon-usemin 27 | ``` 28 | 29 | 在 html 文件中可能有这样的注释块。 30 | 31 | ```html 32 | 33 | 34 | 35 | 36 | ``` 37 | 38 | 在对这个 html 文件执行 webspoon-usemin 之后会将注释块内的文件打包成一个 js 文件(css 也会被转换成 js 以便统一管理),并保存到注释上写着的文件路径中。 39 | 40 | 注: 41 | 1. 无论是读取文件还是写入文件,路径都相对于当前执行的工作目录。 42 | 2. 替换完成后会覆盖原始文件,如果不希望被覆盖请先复制出来。 43 | 44 | ##### webspoon-rev 45 | 46 | 用于给静态资的文件名中加入 hash,同时替换引用文件的引用路径。 47 | 48 | ```bash 49 | webspoon-rev -base -static 50 | ``` 51 | 52 | 传入两个参数 -base 和 -static 它们后面需要跟上一个文件列表。 53 | 54 | base 列表指定的是源文件,如果里面有对 static 列表中文件的引用就会被更新到带 hash 的版本。 55 | 56 | static 列表指定的是静态文件,它会根据其自身内容重命名到带 hash 的版本。 57 | 58 | 注: 59 | 1. base 中对静态资源的引用必须是从当前执行的工作目录到静态文件目录的完整相对路径。 60 | 2. base 列表中的文件更新后会覆盖原始文件,如果不希望被覆盖请先复制出来。 61 | 3. static 列表中的文件操作是被重命名而不是复制,如需备份请提前。 62 | 63 | 64 | ##### webspoon-watch 65 | 66 | 用于监视项目文件,在文件变化时执行相应的命令。 67 | 68 | ```bash 69 | webspoon-watch -target -exec 70 | ``` 71 | 72 | 传入两个参数 -target 和 -exec。 73 | 74 | target 参数用于指定需要监视的文件。 75 | 76 | exec 参数用于指定监视到文件变化后需要执行的脚本。 77 | 78 | 在执行的脚本中可以通过 $src 变量来取到当前处理的文件路径(相对路径)。 79 | 80 | 注: 81 | 1. 只有当文件内容有变化时才会触发。 82 | 2. 通配符初始解析,这意味着开始 watch 之后才创建的文件不会被 watch 到。 83 | 84 | 85 | ##### webspoon-pack 86 | 87 | 用于将静态模板打包成 js 文件 88 | 89 | ```bash 90 | webspoon-pack -moduleName -regexp -replacement 91 | ``` 92 | 93 | 三个可选参数 94 | 95 | * `-moduleName` 模块名,UMD 的模块名 96 | * `-regexp` 用于替换文件路径的正则 97 | * `-replacement` 替换的目标字符串 98 | 99 | 注: 100 | 1. moduleName 缺省值为 templates。 101 | 2. 此处使用 js 的正则,并非 sed。 102 | 103 | 104 | ##### webspoon-wildcard 105 | 106 | 用于解析 html 文件中 SCRIPT/LINK 元素的 src/href 属性中的通配符。 107 | 108 | ```bash 109 | webspoon-wildcard 110 | ``` 111 | 112 | html 文件中这样设置通配符 113 | 114 | ```html 115 | 116 | 117 | ``` 118 | 119 | 将得到大概这样的结果 120 | 121 | ```html 122 | 123 | 124 | 125 | 126 | ``` 127 | 128 | 生成结果中标签上的 `file` 属性可以通过 webspoon-usemin 处理掉。 129 | 130 | 元素上除了 `wildcard` 属性外还有 `root`、`regexp`、`replacement` 三个可选属性。 131 | 132 | * `root` 指定一个站点根目录,用于调整结果中 `src`/`href` 的路径。 133 | * `regexp` 用于替换文件路径的正则 134 | * `replacement` 替换的目标字符串 135 | 136 | 注: 137 | 1. 替换步骤在 root 解析完毕后执行。 138 | 2. 此处使用 js 的正则,并非 sed。 139 | 3. 操作的文件更新后会覆盖原始文件,如果不希望被覆盖请先复制出来。 140 | -------------------------------------------------------------------------------- /src/bin/watch.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import bfs from 'babel-fs'; 4 | import path from 'path'; 5 | import crypto from 'crypto'; 6 | import childProcess from 'child_process'; 7 | import argollector from 'argollector'; 8 | 9 | import globPromise from '../lib/globpromise'; 10 | 11 | 12 | /** 13 | * 声明 14 | **/ 15 | 16 | var watchingSet = new Set(); 17 | 18 | var getHash = file => bfs.readFile(file).then(data => { 19 | let sha1 = crypto.createHash('sha1'); 20 | sha1.update(data); 21 | return sha1.digest('hex'); 22 | }); 23 | 24 | var Env = function(obj) { 25 | for(let key in obj) this[key] = obj[key]; 26 | }; 27 | Env.prototype = process.env; 28 | 29 | var trigger = file => { 30 | var child = childProcess.exec(commandList.join('\n'), { 31 | env: new Env({ src: file }) 32 | }); 33 | child.stdout.pipe(process.stdout); 34 | child.stderr.pipe(process.stderr); 35 | }; 36 | 37 | var watchFile = file => { 38 | return getHash(file).then(hash => { 39 | bfs.watchFile(file, { interval: 500 }, () => { 40 | getHash(file).then(newHash => { 41 | // 只有内容变化的时候才会触发 42 | if(hash === newHash) return; 43 | trigger(file); 44 | hash = newHash; 45 | }, () => {}).catch(e => console.error(e.stack)); 46 | }); 47 | }); 48 | }; 49 | 50 | var watchDirectory = directory => { 51 | return bfs.readdir(directory).then(list => { 52 | list = new Set(list); 53 | list.forEach(name => watch(path.join(directory, name))); 54 | return bfs.watchFile(directory, { interval: 500 }, () => { 55 | // 目录变化时将新增的文件加入 watch 列表 56 | bfs.readdir(directory).then(newList => { 57 | // 检测新增 58 | newList.forEach(name => { 59 | if(list.has(name)) return; 60 | var file = path.join(directory, name); 61 | trigger(file); 62 | watch(file); 63 | }); 64 | // 检测删除 65 | newList = new Set(newList); 66 | list.forEach(name => { 67 | if(newList.has(name)) return; 68 | trigger(path.join(directory, name)); 69 | }); 70 | list = newList; 71 | }, () => {}).catch(e => console.error(e.stack)); 72 | }); 73 | }); 74 | }; 75 | 76 | var watch = file => { 77 | var hash; 78 | if(watchingSet.has(file)) return; 79 | watchingSet.add(file); 80 | bfs.stat(file).then(state => { 81 | switch(true) { 82 | case state.isFile(): 83 | return watchFile(file); 84 | case state.isDirectory(): 85 | return watchDirectory(file); 86 | }; 87 | }).catch(e => console.error(e.stack)); 88 | }; 89 | 90 | 91 | /** 92 | * 收集参数 93 | **/ 94 | 95 | var watchingList = argollector['-target'] || []; 96 | var commandList = argollector['-exec'] || []; 97 | 98 | 99 | 100 | /** 101 | * 主过程 102 | **/ 103 | 104 | // 处理通配符 105 | watchingList = Promise.all(watchingList.map(globPromise)).then(list => [].concat(...list)) 106 | 107 | // watch 每一个文件 108 | .then(list => Promise.all(list.map(watch))) 109 | 110 | // 错误处理 111 | .catch(error => { 112 | console.error(error.stack); 113 | }); 114 | -------------------------------------------------------------------------------- /src/bin/wildcard.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import bfs from 'babel-fs'; 4 | import argollector from 'argollector'; 5 | import capacitance from 'capacitance'; 6 | import path from 'path'; 7 | 8 | import globPromise from '../lib/globpromise'; 9 | 10 | // 这坨代码我自己都觉得烂,然而有更好的写法么? 11 | // 目的是对文件列表做一个排序,并且让同级的文件总是优先于目录 12 | var sortFileList = list => { 13 | for(let i = 0; i < list.length; i++) list[i] = list[i].split(/\//g); 14 | list.sort((a, b) => { 15 | let length = Math.max(a.length, b.length); 16 | for(let i = 0; i < length; i++) { 17 | let fileFirst = !!a[i + 1] - !!b[i + 1]; 18 | if(fileFirst) return fileFirst; 19 | let diff = (a[i] || '').localeCompare(b[i] || ''); 20 | if(diff) return diff; 21 | } 22 | }); 23 | for(let i = 0; i < list.length; i++) list[i] = list[i].join('/'); 24 | }; 25 | 26 | const glop = (...args) => globPromise(...args).then(list => { 27 | sortFileList(list); 28 | return list; 29 | }); 30 | 31 | const root = argollector['-root'] && argollector['-root'][0] || './'; 32 | 33 | // 创建正则,用于从 script/link 中取出各种属性 34 | const re = new RegExp([ 35 | '(\\s*)', 36 | '<(script|link)\\s', 37 | '(?=.*wildcard="(.*?)")', // require 38 | '(?=.*root="(.*?)"|)', // optional 39 | '(?=.*regexp="(.*?)"|)', // optional 40 | '(?=.*replacement="(.*?)"|)', // optional 41 | '.*?(?:>\s*|>)' 42 | ].join(''), 'g'); 43 | 44 | const replaceWildcard = data => { 45 | var taskList = []; 46 | for(let matches; matches = re.exec(data);) { 47 | let [ , space, type, wildcard, root, regexp, replacement ] = matches; 48 | taskList.push(glop(wildcard).then(list => list.map(src => { 49 | let href = path.resolve(src).replace(path.resolve(root || '.'), ''); 50 | if(regexp) href = href.replace(new RegExp(regexp), replacement || ''); 51 | let isSameRoot = href === '/' + src; 52 | var result = []; 53 | switch(type) { 54 | case 'script': 55 | result.push(`${space}`); 58 | break; 59 | case 'link': 60 | result.push(`${space}`); 63 | break; 64 | } 65 | return result.join(''); 66 | }))); 67 | } 68 | return Promise.all(taskList).then(list => { 69 | var i = 0; 70 | return data.replace(re, () => list[i++].join('')); 71 | }); 72 | }; 73 | 74 | 75 | /** 76 | * 主过程 77 | **/ 78 | Promise 79 | // 组织参数,处理通配符 80 | .all(argollector.slice(0).concat(argollector['-files'] || []).map(wildcard => glop(wildcard))) 81 | .then(list => [].concat(...list)) 82 | .then(list => { 83 | if(list.length) { 84 | // 对传入的文件执行 wildcard 85 | return Promise.all(list.map(path => { 86 | return bfs.readFile(path).then(data => ({ path, data: String(data) })); 87 | })).then(list => Promise.all(list.map(item => { 88 | return replaceWildcard(item.data).then(data => bfs.writeFile(item.path, data)); 89 | }))); 90 | } else { 91 | // 对 stdin 的数据执行 wildcard 92 | return process.stdin.pipe(new capacitance()).then(data => { 93 | return replaceWildcard(String(data)).then(data => { 94 | if(process.stdout.write(data)) return; 95 | process.stdout.on('drain', () => process.exit()); 96 | }); 97 | }); 98 | } 99 | }) 100 | // 捕获错误 101 | .catch(console.error); 102 | -------------------------------------------------------------------------------- /src/bin/rev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import crypto from 'crypto'; 4 | import argollector from 'argollector'; 5 | import globPromise from '../lib/globpromise'; 6 | 7 | import { readFile, writeFile, rename } from '../lib/xfs'; 8 | 9 | 10 | /** 11 | * 核心处理器 12 | **/ 13 | 14 | const revProcessorRecursionCounter = Object.create(null); 15 | const revProcessor = ([baseList, staticList]) => 16 | // 计算出所有 staticList 中文件的 hash 版本名 17 | Promise.all(staticList.map(item => { 18 | // 如果是递归过来的,item 就会是一个对象,包含一个真实文件路径和之前的版本路径 19 | // 如果是 glob 出来的,那就是一个字符串,旧版本路径和真实文件路径一致 20 | var realFilePath, oldPath; 21 | if (typeof item === 'object') { 22 | realFilePath = item.realFilePath; 23 | oldPath = item.oldPath; 24 | // 只允许每个文件递归三次,防止循环依赖造成的死循环 25 | if (++revProcessorRecursionCounter[realFilePath] > 3) return null; 26 | } else { 27 | realFilePath = oldPath = item; 28 | revProcessorRecursionCounter[realFilePath] = 0; 29 | } 30 | // 此处不使用 Promise 扁平化是因为文件数据量可能很大,这样可以避免全部文件一起读入内存使内存占用过高 31 | return readFile(realFilePath).then(data => { 32 | var newPath; 33 | var sha1 = crypto.createHash('sha1'); 34 | sha1.update(data); 35 | var hash = sha1.digest('hex').slice(0, 6); 36 | // 如果真实文件路径和旧版本路径是一样的就表示这个文件从未加过版本,所以创建一个新的版本号 37 | if (realFilePath === oldPath) { 38 | newPath = oldPath.replace(/(?=[^.]*$)/, `${hash}.`); 39 | } 40 | // 否则替换原来的版本号 41 | else { 42 | newPath = oldPath.replace(/.{6}(?=\.[^.]*$)/, hash); 43 | } 44 | // 将信息打包成对象,丢给下一个步骤处理 45 | return { realFilePath, oldPath, newPath }; 46 | }); 47 | }).filter(item => item)) 48 | // 将 baseList 中对 staticList 的引用更新到重命名 hash 后的版本,并写回文件 49 | .then(infoList => { 50 | // 为 infoList 创建索引以便访问 51 | var infoListByRealPath = Object.create(null); 52 | var infoListByNewPath = Object.create(null); 53 | infoList.forEach(item => { 54 | infoListByRealPath[item.realFilePath] = item; 55 | infoListByNewPath[item.newPath] = item; 56 | }); 57 | // 将 infoList 中的旧路径提前转换成正则对象以提高性能 58 | var replaces = infoList.map(({ oldPath, newPath }) => ({ 59 | matcher: new RegExp('\\b' + oldPath.replace(/\./g, '\\.') + '\\b', 'g'), 60 | newPath 61 | })); 62 | // 将 baseList 中每个文件内容中包含 infoList 旧路径的东西替换成新的 63 | // 如果 baseList 中的文件本身本身被加过版本号而且又有了变化就先记录下来 64 | var selfChangeList = []; 65 | var tasks = baseList.map(pathname => { 66 | // 此处不使用 Promise 扁平化是因为文件数据量可能很大,这样可以避免全部文件一起读入内存使内存占用过高 67 | return readFile(pathname).then(data => { 68 | data += ''; 69 | // data 是读取到的文件内容,遍历 infoList 做一堆替换操作 70 | var newData = replaces.reduce((base, { matcher, newPath }) => { 71 | return base.replace(matcher, newPath); 72 | }, data); 73 | // 无修改,不操作 74 | if (data === newData) return; 75 | // 如果 baseList 中的文件本身有被替换过版本号,那么就先记录下来 76 | var replaced = infoListByRealPath[pathname]; 77 | if (replaced) selfChangeList.push({ realFilePath: pathname, oldPath: replaced.newPath }); 78 | // 文件回写 79 | return writeFile(pathname, newData); 80 | }); 81 | }); 82 | return Promise.all(tasks).then(() => { 83 | // 如果 selfChangeList 有东西,则递归 revProcessor 去处理 84 | if (selfChangeList.length) return revProcessor([ baseList, selfChangeList ]); 85 | return []; 86 | }).then(list => { 87 | list.forEach(({ oldPath, newPath }) => infoListByNewPath[oldPath].newPath = newPath); 88 | return infoList.map(({ oldPath, newPath }) => ({ oldPath, newPath })); 89 | }); 90 | }); 91 | 92 | 93 | 94 | /** 95 | * 主过程 96 | **/ 97 | 98 | // 从输入参数中读入数据 99 | Promise 100 | 101 | .all([ 102 | Promise.all(argollector['-base'].map(globPromise)).then(list => [].concat(...list)), 103 | Promise.all(argollector['-static'].map(globPromise)).then(list => [].concat(...list)) 104 | ]) 105 | 106 | .then(revProcessor).then(list => { 107 | let tasks = list.map(({ oldPath, newPath }) => { 108 | return rename(oldPath, newPath).then(() => [oldPath, newPath]); 109 | }); 110 | return Promise.all(tasks); 111 | }) 112 | 113 | // 成功 114 | .then(() => { 115 | // TODO: OK 116 | }) 117 | 118 | // 错误处理到 stderr 119 | .catch(error => { 120 | process.stderr.write(`\x1b[31m${error.stack}\x1b[0m\n`); 121 | process.exit(1); 122 | }); 123 | -------------------------------------------------------------------------------- /src/bin/usemin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import http from 'http'; 5 | import https from 'https'; 6 | import crypto from 'crypto'; 7 | import argollector from 'argollector'; 8 | import Capacitance from 'capacitance'; 9 | 10 | import globPromise from '../lib/globpromise'; 11 | import compressor from '../lib/compressor'; 12 | import { readFile, writeFile } from '../lib/xfs'; 13 | 14 | 15 | /** 16 | * 通用声明 17 | **/ 18 | 19 | var blockMatcher = /([\s\S]*?)/g; 20 | var tagMatcher = /<(?:script|link)([\s\S]*?)\/?>/ig; 21 | 22 | class Attrs { 23 | toHTMLTag() { 24 | let obj = JSON.parse(JSON.stringify(this)); 25 | let { file, href } = obj; 26 | delete obj.href; 27 | delete obj.file; 28 | const getAttrsString = () => Object.keys(obj).map(key => `${key}="${obj[key]}"`).join(' '); 29 | let result; 30 | switch(String(/\.[^.]*$/.exec(file)).toLowerCase()) { 31 | case '.js': 32 | obj.src = href; 33 | return ``; 34 | case '.css': 35 | obj.href = href; 36 | obj.rel = obj.rel || 'stylesheet'; // 默认的 rel 属性使用 stylesheet 37 | return ``; 38 | default: 39 | throw new Error('目前 块仅支持生成 js 和 css 文件'); 40 | }; 41 | } 42 | constructor(string) { 43 | for(let i, r = /([^= ]+)(?:="(.*?)")?/g; i = r.exec(string);) { 44 | let [ , key, value ] = i; 45 | if(value === void 0) value = key; 46 | this[key] = value; 47 | } 48 | } 49 | }; 50 | 51 | var matchUsemin = string => { 52 | var attrs = new Attrs(string); 53 | attrs.href = attrs.href || attrs.src; 54 | delete attrs.src; 55 | if (attrs.file === void 0 && attrs.href) attrs.file = attrs.href.replace(/^\/(?!\/)/, ''); 56 | if (/^\/\//.test(attrs.file)) attrs.file = 'http:' + attrs.file; 57 | return attrs; 58 | }; 59 | 60 | var loadRemoteDataCache = {}; 61 | var loadRemoteData = url => { 62 | if (loadRemoteDataCache[url]) return loadRemoteDataCache[url]; 63 | return loadRemoteDataCache[url] = new Promise((resolve, reject) => { 64 | (/^https/.test(url) ? https : http).get(url, res => { 65 | res.pipe(new Capacitance()).then(String).then(resolve, reject); 66 | }).on('error', error => { 67 | var { code } = error; 68 | if (code === 'EHOSTUNREACH') code += ' (Can\'t connect to ' + error.address + ')'; 69 | reject([ 70 | code, 71 | ' at loadRemoteData("' + url + '")' 72 | ].join('\n')); 73 | }); 74 | }); 75 | }; 76 | 77 | 78 | /** 79 | * 主过程 80 | **/ 81 | 82 | // 从输入参数中读入数据 83 | Promise 84 | 85 | // 组织参数,处理通配符 86 | .all(argollector.slice(0).concat(argollector['--files'] || []).map(globPromise)) 87 | 88 | .then(list => { 89 | list = [].concat(...list); 90 | list.sort(); 91 | return list; 92 | }) 93 | 94 | // 打包 js 和 css 文件 95 | .then(list => { 96 | var tasks = []; 97 | var cache = {}; 98 | return Promise.all(list.map(pathname => { 99 | // 此处不使用 Promise 扁平化是因为文件数据量可能很大,这样可以避免全部文件一起读入内存使内存占用过高 100 | return readFile(pathname).then(data => { 101 | data += ''; 102 | data = data.replace(blockMatcher, ($0, configs, content) => { 103 | configs = matchUsemin(configs); 104 | if (!configs.file || !configs.href) { 105 | throw new Error('Missing essential attributes for blocks:\n' + $0); 106 | } 107 | // 计算 output 108 | var output = configs.toHTMLTag(); 109 | // 从 HTML 片段中搜索 href 和 filte 110 | var list = []; 111 | while (tagMatcher.exec(content)) list.push(matchUsemin(RegExp.$1)); 112 | var resources = JSON.stringify(list.map(item => item.file)); 113 | // 检测重复资源 114 | if(cache[configs.file]) { 115 | if(cache[configs.file].resources !== resources) { 116 | throw new Error([ 117 | `The dist file ${configs.file} has conflict`, 118 | '', 119 | 'A = ' + cache[configs.file].resources, 120 | 'B = ' + resources 121 | ].join('\n')); 122 | } 123 | return cache[configs.file].output; 124 | } 125 | // 创建缓存 126 | cache[configs.file] = { resources, output }; 127 | // 读入 list 128 | list.forEach((item, index) => { 129 | let loader =/^https?:/.test(item.file) ? loadRemoteData(item.file) : readFile(item.file, 'utf8'); 130 | if (configs.file.match(/\.js$/)) { 131 | loader = loader.then(data => { 132 | // 先不压缩,有个莫名其妙的 Bug @ 2016-01-28 133 | return data; 134 | // 压缩 js 里面的 css 135 | return /\.css$/.test(item.file) ? compressor.css(data) : data; 136 | }).then(data => { 137 | // 将 css 转换成 js,并和其他 JS 一起合并起来 138 | if (/\.css$/.test(item.file)) { 139 | data = `document.write(${JSON.stringify('')});`; 140 | } else if (!item.file.match(/\.js$/)) { 141 | throw new Error('Not supported source file type: ' + item.file); 142 | } 143 | return data.toString(); 144 | }); 145 | } else if (configs.file.match(/\.css$/)) { 146 | if (item.file.match(/\.css$/)) { 147 | loader = loader.then(data => data.toString()); 148 | } else { 149 | throw new Error('Not supported source file type: ' + item.file); 150 | } 151 | } else { 152 | throw new Error('Not supported target file type: ' + configs.file) 153 | } 154 | list[index] = loader; 155 | }); 156 | // 保存文件 157 | var task = Promise.all(list).then(list => { 158 | if (/\.js$/.test(configs.file)) { 159 | return compressor.js(list.join(';\n')); 160 | } else { 161 | return compressor.css(list.join('\n')); 162 | } 163 | }).then(result => { 164 | return writeFile(configs.file, result); 165 | }, error => { 166 | if(typeof error === 'string') throw new Error(error); 167 | throw new Error([ 168 | error.constructor.name + ': ', 169 | ' on error.message: ' + error.message, 170 | ' on pathname: ' + pathname, 171 | ' on configs.file: ' + configs.file 172 | ].join('\n')); 173 | }); 174 | // 保存任务并替换字符串 175 | tasks.push(task); 176 | return output; 177 | }); 178 | return writeFile(pathname, data); 179 | }); 180 | })).then(() => Promise.all(tasks)); 181 | }) 182 | 183 | // 成功 184 | .then(data => { 185 | process.exit(0); 186 | }) 187 | 188 | // 错误处理到 stderr 189 | .catch(error => { 190 | process.stderr.write('\n' + error.stack + '\n'); 191 | process.exit(1); 192 | }); 193 | --------------------------------------------------------------------------------