├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── houl.js ├── docs ├── .vuepress │ └── config.js ├── README.md ├── guide │ ├── README.md │ ├── command.md │ ├── config.md │ └── task.md └── ja │ ├── README.md │ └── guide │ ├── README.md │ ├── command.md │ ├── config.md │ ├── installation.md │ └── task.md ├── example ├── .gitignore ├── houl.config.json ├── houl.task.js ├── package.json └── src │ ├── css │ ├── index.scss │ └── internal │ │ └── _variables.scss │ ├── index.html │ └── js │ ├── index.js │ └── vendor │ └── lib.js ├── lib ├── api.js ├── build.js ├── cache-stream.js ├── cache.js ├── cli │ ├── build.js │ ├── dev.js │ └── watch.js ├── color.js ├── config.js ├── dep-cache.js ├── dep-resolver.js ├── dev.js ├── externals │ ├── browser-sync.js │ └── watcher.js ├── loggers │ ├── build-logger.js │ ├── dev-logger.js │ └── watch-logger.js ├── models │ ├── config.js │ ├── rule.js │ └── task.js ├── task-stream.js ├── util.js └── watch.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── release.sh └── test ├── .eslintrc.yml ├── e2e ├── build.spec.js ├── dev.spec.js ├── setup.js └── watch.spec.js ├── expected ├── added │ ├── css │ │ └── index.css │ ├── example.html │ ├── index.html │ └── js │ │ ├── index.js │ │ └── vendor │ │ └── lib.js ├── cache │ ├── css │ │ └── index.css │ └── js │ │ └── index.js ├── dev │ ├── css │ │ └── index.css │ ├── index.html │ └── js │ │ ├── index.js │ │ └── vendor │ │ └── lib.js ├── dot │ ├── .htaccess │ ├── css │ │ └── index.css │ ├── index.html │ └── js │ │ ├── index.js │ │ └── vendor │ │ └── lib.js ├── filtered │ └── css │ │ └── index.css ├── prod │ ├── css │ │ └── index.css │ ├── index.html │ └── js │ │ ├── index.js │ │ └── vendor │ │ └── lib.js └── updated │ ├── css │ └── index.css │ ├── index.html │ └── js │ ├── index.js │ └── vendor │ └── lib.js ├── fixtures ├── configs │ ├── no-taskfile.js │ ├── normal-with-preset-modify.js │ ├── normal-with-preset-options.js │ ├── normal.config.js │ ├── normal.task.js │ ├── preset-function.config.js │ ├── preset.config.js │ ├── preset.task.js │ └── test.config.json ├── e2e │ ├── .gitignore │ ├── added-src │ │ └── example.pug │ ├── houl.config.json │ ├── houl.task.js │ ├── package.json │ ├── src │ │ ├── .htaccess │ │ ├── _layouts │ │ │ └── default.pug │ │ ├── css │ │ │ ├── _variables.scss │ │ │ └── index.scss │ │ ├── index.pug │ │ └── js │ │ │ ├── _excluded.js │ │ │ ├── index.js │ │ │ └── vendor │ │ │ └── lib.js │ └── updated-src │ │ ├── css │ │ └── _variables.scss │ │ └── js │ │ ├── _excluded.js │ │ └── index.js └── sources │ ├── index.html │ ├── index.js │ ├── index.scss │ └── sources │ └── index.js ├── helpers ├── e2e.js └── index.js ├── jasmine-e2e.json ├── jasmine-helpers.js ├── jasmine-unit.json └── specs ├── api.spec.js ├── cache-stream.spec.js ├── cache.spec.js ├── config.spec.js ├── dep-cache.spec.js ├── dep-resolver.spec.js ├── env ├── dev-task.js └── prod-task.js ├── externals └── browser-sync.spec.js ├── loggers ├── build-logger.spec.js ├── dev-logger.spec.js └── watch-logger.spec.js ├── models ├── config.spec.js └── rule.spec.js ├── task-stream.spec.js └── util.spec.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | node-8: &node-8 4 | working_directory: ~/working/node-8 5 | docker: 6 | - image: circleci/node:8 7 | 8 | node-10: &node-10 9 | working_directory: ~/working/node-10 10 | docker: 11 | - image: circleci/node:10 12 | 13 | install: &install 14 | steps: 15 | - checkout 16 | 17 | # Download and cache dependencies 18 | - restore_cache: 19 | keys: 20 | - v2-dependencies-{{ checksum "package-lock.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v2-dependencies- 23 | 24 | - run: npm install 25 | 26 | - save_cache: 27 | paths: 28 | - node_modules 29 | key: v2-dependencies-{{ checksum "package-lock.json" }} 30 | 31 | - persist_to_workspace: 32 | root: ~/working 33 | paths: 34 | - node-8 35 | - node-10 36 | 37 | lint: &lint 38 | steps: 39 | - attach_workspace: 40 | at: ~/working 41 | - run: npm run lint 42 | 43 | test-unit: &test-unit 44 | steps: 45 | - attach_workspace: 46 | at: ~/working 47 | - run: npm run test:unit 48 | 49 | test-e2e: &test-e2e 50 | steps: 51 | - attach_workspace: 52 | at: ~/working 53 | - run: npm run test:e2e 54 | 55 | jobs: 56 | node-8-install: 57 | <<: *node-8 58 | <<: *install 59 | 60 | node-8-lint: 61 | <<: *node-8 62 | <<: *lint 63 | 64 | node-8-test-unit: 65 | <<: *node-8 66 | <<: *test-unit 67 | 68 | node-8-test-e2e: 69 | <<: *node-8 70 | <<: *test-e2e 71 | 72 | node-10-install: 73 | <<: *node-10 74 | <<: *install 75 | 76 | node-10-lint: 77 | <<: *node-10 78 | <<: *lint 79 | 80 | node-10-test-unit: 81 | <<: *node-10 82 | <<: *test-unit 83 | 84 | node-10-test-e2e: 85 | <<: *node-10 86 | <<: *test-e2e 87 | 88 | workflows: 89 | version: 2 90 | build_and_test: 91 | jobs: 92 | - node-8-install 93 | - node-8-lint: 94 | requires: 95 | - node-8-install 96 | - node-8-test-unit: 97 | requires: 98 | - node-8-install 99 | - node-8-test-e2e: 100 | requires: 101 | - node-8-install 102 | - node-10-install 103 | - node-10-lint: 104 | requires: 105 | - node-10-install 106 | - node-10-test-unit: 107 | requires: 108 | - node-10-install 109 | - node-10-test-e2e: 110 | requires: 111 | - node-10-install -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.{js,json,yml}] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: eslint-config-ktsn 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test/fixtures/dist/ 3 | /docs/.vuepress/dist/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 katashin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Houl 2 | 3 | [![npm version](https://badge.fury.io/js/houl.svg)](https://badge.fury.io/js/houl) 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/ktsn/houl.svg)](https://greenkeeper.io/) 5 | 6 | Full-contained static site workflow 7 | 8 | ## Documentation 9 | 10 | You can read Houl documentation on [https://houl.netlify.com/](https://houl.netlify.com/) 11 | 12 | ## License 13 | 14 | MIT 15 | -------------------------------------------------------------------------------- /bin/houl.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('yargs') // eslint-disable-line 4 | .version() 5 | .usage('Usage: $0 [options]') 6 | .command( 7 | 'build', 8 | 'Build your project with config and tasks', 9 | require('../lib/cli/build') 10 | ) 11 | .command('dev', 'Start development server', require('../lib/cli/dev')) 12 | .command( 13 | 'watch', 14 | 'Watch the input directory and build updated files incrementally', 15 | require('../lib/cli/watch') 16 | ) 17 | .demandCommand(1) 18 | .help('h') 19 | .alias('h', 'help').argv 20 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | locales: { 3 | '/': { 4 | lang: 'en-US', 5 | title: 'Houl', 6 | description: 'Full-contained static site workflow', 7 | }, 8 | 9 | '/ja/': { 10 | lang: 'ja-JP', 11 | title: 'Houl', 12 | description: '全部入りの静的サイトワークフロー', 13 | } 14 | }, 15 | 16 | themeConfig: { 17 | locales: { 18 | '/': { 19 | selectText: 'Languages', 20 | label: 'English', 21 | 22 | nav: [ 23 | { text: 'User Guide', link: '/guide/' }, 24 | { text: 'GitHub', link: 'https://github.com/oro-oss/houl' } 25 | ], 26 | 27 | sidebar: { 28 | '/guide/': [ 29 | '', 30 | 'command', 31 | 'config', 32 | 'task' 33 | ] 34 | } 35 | }, 36 | 37 | '/ja/': { 38 | selectText: '言語', 39 | label: '日本語', 40 | 41 | nav: [ 42 | { text: 'ユーザーガイド', link: '/ja/guide/' }, 43 | { text: 'GitHub', link: 'https://github.com/oro-oss/houl' } 44 | ], 45 | 46 | sidebar: { 47 | '/ja/guide/': [ 48 | '', 49 | 'command', 50 | 'config', 51 | 'task' 52 | ] 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | actionText: Get Started → 4 | actionLink: /guide/ 5 | footer: MIT Licensed | Copyright © 2017-present katashin 6 | --- 7 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Houl is created for simplifying a common workflow of static site coding. It builds source files with customizable tasks, watches source changes and serves built files. 4 | 5 | Since Houl abstracts the common workflow as its own feature, you will not be annoyed with complex config files any more. All you have to do is just declare how to transform each file and which file the transformation is applied. 6 | 7 | ## Installation 8 | 9 | Install Houl from npm: 10 | 11 | ```bash 12 | # npm 13 | $ npm install -g houl 14 | 15 | # yarn 16 | $ yarn global add houl 17 | ``` 18 | 19 | ## Simple Example 20 | 21 | Let's look into how to transform your `.pug` and `.scss` files with Houl. Install depedencies at first: 22 | 23 | ```bash 24 | $ npm install -D gulp-pug gulp-sass 25 | ``` 26 | 27 | Then write a task file (`houl.task.js`) which you declare how to transform for each file: 28 | 29 | ```js 30 | // houl.task.js 31 | const pug = require('gulp-pug') 32 | const sass = require('gulp-sass') 33 | 34 | exports.pug = stream => { 35 | return stream.pipe(pug()) 36 | } 37 | 38 | exports.sass = stream => { 39 | return stream.pipe(sass()) 40 | } 41 | ``` 42 | 43 | You also specify a directory path of the source/destination and which file the transformation is applied in a config JSON file (`houl.config.json`): 44 | 45 | ```json 46 | { 47 | "input": "src", 48 | "output": "dist", 49 | "taskFile": "houl.task.js", 50 | "rules": { 51 | "pug": "pug", 52 | "scss": { 53 | "task": "sass", 54 | "outputExt": "css" 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | Build the `src` directory after adding some source files by following command. The output is in `dist` directory: 61 | 62 | ```bash 63 | $ houl build 64 | ``` 65 | 66 | The configurations are quite simple because Houl automatically handle a dev server and watching. The important thing is that you can use any Gulp plugins in a Houl task file. So you would easily migrate your Gulp workflow to Houl. 67 | -------------------------------------------------------------------------------- /docs/guide/command.md: -------------------------------------------------------------------------------- 1 | # Command 2 | 3 | Houl provides three commands - `build`, `dev` and `watch`. 4 | 5 | ```bash 6 | $ houl build 7 | $ houl dev 8 | $ houl watch 9 | ``` 10 | 11 | `houl build` transform/copy all source files into destination directory that is written in a config file. 12 | 13 | `houl dev` starts a dev server (powered by [BrowserSync](https://browsersync.io/)). The dev server dynamically transform a source file when a request is recieved, then you will not suffer the perfomance problem that depends on the size of static site. 14 | 15 | `houl watch` is similar with `houl dev` but it does not start dev server. It watches and builds updated files incrementally. This command is useful in a project that requires some additional processing for asset files such as the asset pipeline of Ruby on Rails. 16 | 17 | ## `--config` (`-c`) option 18 | 19 | Houl automatically loads `houl.config.js` or `houl.config.json` as a config file but you can use `--config` (shorthand `-c`) option if you prefer to load other config file. 20 | 21 | ```bash 22 | $ houl build -c config.js 23 | $ houl dev -c config.js 24 | $ houl watch -c config.js 25 | ``` 26 | 27 | ## `--dot` flag 28 | 29 | If you want to include dot files (e.g. `.htaccess`) in input, set `--dot` flag with `build` and `watch` command. 30 | 31 | ```bash 32 | $ houl build --dot 33 | $ houl watch --dot 34 | ``` 35 | 36 | ## `--production` flag 37 | 38 | You can enable production mode by adding `--production` flag with `build` command that will set `process.env.NODE_ENV` to `'production'`: 39 | 40 | ```bash 41 | $ houl build --production 42 | ``` 43 | 44 | ## `--cache` option 45 | 46 | You may want to cache each build file and process only updated files in the next build. Houl provides this feature for you by setting `--cache` option. 47 | 48 | ```bash 49 | $ houl build --cache .houlcache 50 | $ houl watch --cache .houlcache 51 | ``` 52 | 53 | Note that the file name that is specified with `--cache` option (`.houlcache` in the above example) is a cache file to check updated files since the previous build. You need to specify the same file on every build to make sure to work the cache system correctly. 54 | 55 | The cache system will traverse dependencies to check file updates strictly. The dependencies check works out of the box for the most of file formats thanks to [progeny](https://github.com/es128/progeny). But you may need to adapt a new file format or modify progeny configs for your project. In that case, you can pass progeny configs into each rules (you will learn about _rules_ in a later section). 56 | 57 | ```json 58 | { 59 | "rules": { 60 | "js": { 61 | "task": "scripts", 62 | "progeny": { 63 | "extension": "es6" 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ## `--filter` option 71 | 72 | There are some cases that we want to build a part of source files in a project for various purpose (e.g. generating styleguide by using built css files). For that case, we can use `--filter` option to specify which files should be build by glob pattern. 73 | 74 | ```bash 75 | $ houl build --filter **/*.scss 76 | ``` 77 | 78 | ## `--port` (`-p`) option 79 | 80 | If you want to specify a listen port of the dev server, you can set `--port` (shorthand `-p`) option. 81 | 82 | ```bash 83 | $ houl dev -p 50000 84 | ``` 85 | 86 | ## `--base-path` option 87 | 88 | Sometimes, we may want to serve a part of a Web site by the dev server. For example, let imagine the following file structure: 89 | 90 | ``` 91 | - src 92 | |- index.html 93 | |- js/ 94 | |- css/ 95 | |- img/ 96 | |- ... 97 | ``` 98 | 99 | If we want to access `src/index.html` via `http://localhost:8080/sub/index.html` in that case, we can use `--base-path` option. 100 | 101 | ```bash 102 | $ houl dev --base-path sub 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/guide/config.md: -------------------------------------------------------------------------------- 1 | # Config File 2 | 3 | Houl config file can be `.json` or `.js` that exports config object. It specifies the project source/destination directory, the way how it transforms sources and so on. Available options are following: 4 | 5 | | Key | Description | 6 | | -------------- | ------------------------------------------------------------------------ | 7 | | input | Path to source directory | 8 | | output | Path to destination directory | 9 | | exclude | Glob pattern(s) of files that will be ignored from input | 10 | | taskFile | Path to task file that is described in the later section | 11 | | preset | Preset package name or an object that specify a preset | 12 | | preset.name | Preset package name | 13 | | preset.options | Preset options | 14 | | rules | Specify how to transform source files | 15 | | dev | Dev server related options (See [Dev options](#dev-options) for details) | 16 | 17 | ## Rules 18 | 19 | You can specify the way how to transform the source files by _rules_. The `rules` field in config file should be an object and its keys indicate target extensions for transformation. For example, if you want to transform `.js` files, you should add `js` field in `rules` object. 20 | 21 | Each field in `rules` object can be an object, a string or a function. If string or function is specified, it will be treated as `task`. 22 | 23 | | Key | Description | 24 | | --------- | ----------------------------------------------------------------------------------------------------------- | 25 | | task | Task name or inline task that will apply transformations | 26 | | outputExt | Extension of output files. If omitted, it is same as input files' extensions. | 27 | | exclude | Glob pattern(s) of files that will not be applied the rule | 28 | | progeny | Specify [progeny configs](https://github.com/es128/progeny#configuration) for the corresponding file format | 29 | | options | Options for the corresponding task that is passed to the 2nd argument of the task | 30 | 31 | ## Preset 32 | 33 | Houl can load external preset that distributed on NPM. You can load it by specifying `preset` field of the config file. For example, if you want to use `houl-preset-foo` preset, just write the package name to `preset`. 34 | 35 | ```json 36 | { 37 | "input": "./src", 38 | "output": "./dist", 39 | "preset": "houl-preset-foo" 40 | } 41 | ``` 42 | 43 | ### Specifying preset options 44 | 45 | A preset receives any options value if you set a `options` property in the `preset` field. 46 | 47 | ```json 48 | { 49 | "input": "./src", 50 | "output": "./dist", 51 | "preset": { 52 | "name": "houl-preset-foo", 53 | "options": { 54 | "exclude": "**/_*/**" 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | The specified options can be referred in the config file of the preset if it is defined as a function style. 61 | 62 | ```js 63 | module.exports = function(options) { 64 | return { 65 | exclude: options.exclude, 66 | rules: { 67 | // ... 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ### Extending preset config 74 | 75 | You may want to extend an existing preset rules to adapt your own needs. In that case, you just specify additional options for corresponding rules. 76 | 77 | For example, when the preset config is like the following: 78 | 79 | ```js 80 | { 81 | "rules": { 82 | "js": { 83 | "task": "script" 84 | } 85 | } 86 | } 87 | ``` 88 | 89 | The user config: 90 | 91 | ```js 92 | { 93 | "preset": "houl-preset-foo", 94 | "rules": { 95 | "js": { 96 | "exclude": "**/_*/**" 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | The above user config is the same as the following config: 103 | 104 | ```js 105 | { 106 | "rules": { 107 | "js": { 108 | "task": "script", 109 | "exclude": "**/_*/**" 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | If you want to tweak presets more flexible, you can use `preset.modifyConfig` option. `modifyConfig` expects a function that receives a raw preset config object as the 1st argument. You modify the preset config in the function or optionally return new config object. 116 | 117 | ```js 118 | module.exports = { 119 | input: './src', 120 | output: './dist', 121 | preset: { 122 | name: 'houl-preset-foo', 123 | modifyConfig: config => { 124 | // Remove `foo` task in the preset 125 | // You have to use `delete` statement instead of 126 | // assigning `null` to remove a task. 127 | delete config.rules.foo 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | ## Dev options 134 | 135 | You can provide dev server related options via `dev` field. The `dev` field has an object which can include the following properties. 136 | 137 | | Key | Description | 138 | | -------- | -------------------------------------------------------------------------------------------------------------------------------- | 139 | | proxy | Proxy configurations which is compatible with [`node-http-proxy` options](https://github.com/nodejitsu/node-http-proxy#options). | 140 | | port | Port number of the dev server as same as the `--port` cli option. | 141 | | basePath | Base path of the dev server as same as the `--base-path` cli option. | 142 | 143 | The below is an example of `proxy` configuration: 144 | 145 | ```json 146 | { 147 | "dev": { 148 | "proxy": { 149 | "/foo": "http://foo.com/", 150 | "/bar": { 151 | "target": "https://bar.com/", 152 | "secure": true 153 | } 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | The key of the `proxy` object indicates which requests for the path should be proxied. The above config let the dev server proxy requests under `/foo` to `http://foo.com/` and `/bar` to `https://bar.com/`. 160 | 161 | ## Config Example 162 | 163 | Full example of config file: 164 | 165 | ```json 166 | { 167 | "input": "./src", 168 | "output": "./dist", 169 | "exclude": ["**/_*", "**/private/**"], 170 | "taskFile": "./houl.task.js", 171 | "preset": "houl-preset-foo", 172 | "rules": { 173 | "js": { 174 | "task": "scripts", 175 | "exclude": "**/vendor/**", 176 | "progeny": { 177 | "extension": "es6" 178 | } 179 | }, 180 | "scss": { 181 | "task": "styles", 182 | "outputExt": "css", 183 | "options": { 184 | "fooValue": "foo" 185 | } 186 | } 187 | } 188 | } 189 | ``` 190 | -------------------------------------------------------------------------------- /docs/guide/task.md: -------------------------------------------------------------------------------- 1 | # Task File 2 | 3 | The task file contains how to transform source files by Houl. Interesting point is the task file is compatible with any [Gulp](http://gulpjs.com/) plugins. That means you can utilize rich gulp ecosystem. 4 | 5 | The task file must be `.js` file and you need to export some functions. The exported functions receive a stream that will send source files then you must return a piped stream that transforms them. The 2nd argument of the function will be an options value that specified in each rule in the config file. You can use any Gulp plugins to pipe the stream: 6 | 7 | ```javascript 8 | const babel = require('gulp-babel') 9 | const sass = require('gulp-sass') 10 | 11 | exports.scripts = stream => { 12 | return stream.pipe(babel()) 13 | } 14 | 15 | exports.styles = (stream, options) => { 16 | return stream.pipe(sass(options.sass)) 17 | } 18 | ``` 19 | 20 | Note that the exported name is used on config file (e.g. If you write `exports.scripts`, you can refer it as `"scripts"` task in the config file). 21 | 22 | ## Inline Task 23 | 24 | You can also write task function in config file directory. `task` option in rule objects can receive task function: 25 | 26 | ```javascript 27 | const babel = require('gulp-babel') 28 | const sass = require('gulp-sass') 29 | 30 | module.exports = options => { 31 | return { 32 | rules: { 33 | js: { 34 | task: stream => { 35 | return stream.pipe(babel()) 36 | } 37 | }, 38 | scss: { 39 | task: stream => { 40 | return stream.pipe(sass(options.sass)) 41 | }, 42 | outputExt: 'css' 43 | } 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ## Task Helpers 50 | 51 | If you want to execute environment specific transformation, you can use `dev` and `prod` helpers: 52 | 53 | ```javascript 54 | const { dev, prod } = require('houl') 55 | const babel = require('gulp-babel') 56 | const sourcemaps = require('gulp-sourcemaps') 57 | const uglify = require('gulp-uglify') 58 | 59 | exports.scripts = stream => { 60 | return stream 61 | .pipe(dev(soucemaps.init())) // Generate source maps in development mode 62 | .pipe(babel()) 63 | .pipe(prod(uglify())) // Minify in production mode 64 | .pipe(dev(soucemaps.write())) 65 | } 66 | ``` 67 | 68 | You can enable production mode by using `--production` flag with `build` command. 69 | -------------------------------------------------------------------------------- /docs/ja/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | actionText: 始める → 4 | actionLink: /ja/guide/ 5 | footer: MIT Licensed | Copyright © 2017-present katashin 6 | --- 7 | -------------------------------------------------------------------------------- /docs/ja/guide/README.md: -------------------------------------------------------------------------------- 1 | # はじめに 2 | 3 | Houl は静的サイトコーディングの共通ワークフローを単純にするために作られています。カスタマイズ可能なタスクでソースファイルをビルドしたり、変更をウォッチしたり、開発サーバーを立てたりできます。 4 | 5 | Houl は共通のワークフローを自身の機能として抽象化しているので、設定ファイルが複雑になってイライラすることはもうありません。ファイルをどのように変換するか、どのファイルにそれを適用するかということだけを定義すればよいです。 6 | 7 | ## インストール 8 | 9 | npm から Houl をインストールします。 10 | 11 | ```bash 12 | # npm 13 | $ npm install -g houl 14 | 15 | # yarn 16 | $ yarn global add houl 17 | ``` 18 | 19 | ## 単純な例 20 | 21 | Houl で `.pug` や `.scss` をどのように変換するかを見ていきましょう。初めに依存関係をインストールします。 22 | 23 | ```bash 24 | $ npm install -D gulp-pug gulp-sass 25 | ``` 26 | 27 | 次に、それぞれのファイルをどのように変換するかを定義したタスクファイル (`houl.task.js`) を書きます。 28 | 29 | ```js 30 | // houl.task.js 31 | const pug = require('gulp-pug') 32 | const sass = require('gulp-sass') 33 | 34 | exports.pug = stream => { 35 | return stream.pipe(pug()) 36 | } 37 | 38 | exports.sass = stream => { 39 | return stream.pipe(sass()) 40 | } 41 | ``` 42 | 43 | さらに、JSON の設定ファイル (`houl.config.json`) にソースコードのあるディレクトリ、出力先ディレクトリや、変換をどのファイルに適用するかを指定します。 44 | 45 | ```json 46 | { 47 | "input": "src", 48 | "output": "dist", 49 | "taskFile": "houl.task.js", 50 | "rules": { 51 | "pug": "pug", 52 | "scss": { 53 | "task": "sass", 54 | "outputExt": "css" 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | 何らかのソースファイルを `src` ディレクトリに追加した後、次のコマンドでビルドします。出力は `dist` ディレクトリに入ります。 61 | 62 | ```bash 63 | $ houl build 64 | ``` 65 | 66 | Houl は自動的に開発サーバーやファイル監視を扱ってくれるので、設定はとてもシンプルになります。重要な点として、任意の Gulp プラグインを Houl のタスクで使用できるということがあります。したがって、Gulp のワークフローを簡単に Houl に移行することができます。 67 | -------------------------------------------------------------------------------- /docs/ja/guide/command.md: -------------------------------------------------------------------------------- 1 | # コマンド 2 | 3 | Houl は `build`、`dev`、`watch` の3つのコマンドを提供しています。 4 | 5 | ```bash 6 | $ houl build 7 | $ houl dev 8 | $ houl watch 9 | ``` 10 | 11 | `houl build` は設定ファイルに指定された通りに、すべてのソースコードを出力先ディレクトリに変換、および、コピーします。 12 | 13 | `houl dev` は開発サーバー ([BrowserSync](https://browsersync.io/) を使用してます) を立てます。開発サーバーはリクエストを受け取ると、対応するソースファイルを動的に変換します。これにより、静的サイトが大きくなるに連れ、ビルド時間が長くなるようなパフォーマンスの問題は発生しなくなっています。 14 | 15 | `houl watch` は `houl dev` と似たコマンドですが、開発サーバーは立てません。`watch` コマンドはファイルの変更をウォッチし、変更があったもののみをビルドします。Ruby on Rails のアセットパイプラインのような、出力ファイルに何らかの追加処理を行いたいケースで使えるでしょう。 16 | 17 | ## `--config` (`-c`) 18 | 19 | Houl は自動的に `houl.config.js`または `houl.config.json` を設定ファイルとして読み込みますが、`--config` (短縮記法 `-c`) オプションを使うことで、明示的に設定ファイルを指定できます。 20 | 21 | ```bash 22 | $ houl build -c config.js 23 | $ houl dev -c config.js 24 | $ houl watch -c config.js 25 | ``` 26 | 27 | ## `--dot` 28 | 29 | もしドットファイル (例えば `.htaccess`) をビルドに含めたいときは、`--dot` フラグを `build` または `watch` コマンドに付与します。 30 | 31 | ```bash 32 | $ houl build --dot 33 | $ houl watch --dot 34 | ``` 35 | 36 | ## `--production` 37 | 38 | `--production` フラグを `build` コマンドに付与することで本番モードを有効にすることができます。これは `process.env.NODE_ENV` を `'production'` に設定します。 39 | 40 | ```bash 41 | $ houl build --production 42 | ``` 43 | 44 | ## `--cache` 45 | 46 | 前のビルドから変更されたファイルのみをビルドしたいケースについて考えます。Houl では `--cache` オプションを指定することでそのような機能を使うことができます。 47 | 48 | ```bash 49 | $ houl build --cache .houlcache 50 | $ houl watch --cache .houlcache 51 | ``` 52 | 53 | `--cache` で指定されたファイル名 (上記では `.houlcache`) は前のビルドからアップデートされたファイルをチェックするためのキャッシュファイルです。キャッシュを正しく動作させるために、毎回のビルドで同じファイルを指定する必要があります。 54 | 55 | ファイルの更新を厳密に判定するためにファイルの依存関係の走査も行います。ほとんどのファイル形式は [progeny](https://github.com/es128/progeny) によってデフォルトでサポートされており、依存関係を処理することができます。もし新たなファイル形式を使いたかったり、progeny の設定をプロジェクトに合わせて変えたい場合は、progeny の設定をそれぞれのルールに適用することもできます (_ルール_ については後の節で説明します)。 56 | 57 | ```json 58 | { 59 | "rules": { 60 | "js": { 61 | "task": "scripts", 62 | "progeny": { 63 | "extension": "es6" 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ## `--filter` 71 | 72 | 例えば、ビルド後の CSS ファイルを使ってスタイルガイドを作成したいときなど、一部のソースコードのみをビルドしたい場合があります。そのような場合は `--filter` オプションに glob パターンを渡すことで、ビルド対象のファイルを指定することができます。 73 | 74 | ```bash 75 | $ houl build --filter **/*.scss 76 | ``` 77 | 78 | ## `--port` (`-p`) 79 | 80 | 開発サーバーが開くポートを指定したい場合は `--port` (短縮記法 `-p`) で指定できます。 81 | 82 | ```bash 83 | $ houl dev -p 50000 84 | ``` 85 | 86 | ## `--base-path` 87 | 88 | 開発サーバーで Web サイトの一部のディレクトリを配信したい場合があります。例えば、次のようなフォルダ構造を考えます。 89 | 90 | ``` 91 | - src 92 | |- index.html 93 | |- js/ 94 | |- css/ 95 | |- img/ 96 | |- ... 97 | ``` 98 | 99 | `src/index.html` に対して `http://localhost:8080/sub/index.html` という URL でアクセスしたい場合、`--base-path` オプションを使えます。 100 | 101 | ```bash 102 | $ houl dev --base-path sub 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/ja/guide/config.md: -------------------------------------------------------------------------------- 1 | # 設定ファイル 2 | 3 | Houl の設定ファイルには `.json` もしくは、オブジェクトをエクスポートする `.js` ファイルを使うことができます。設定ファイルにはプロジェクトのソースコードが入ったディレクトリ、出力先ディレクトリ、ソースコードをどのように変換するか、などを設定することができます。利用可能なオプションは以下のとおりです。 4 | 5 | | キー | 説明 | 6 | | -------------- | -------------------------------------------------------------------- | 7 | | input | ソースコードの入っているディレクトリ | 8 | | output | 出力先ディレクトリ | 9 | | exclude | input 内で無視するファイルの glob パターン (文字列または配列) | 10 | | taskFile | タスクファイルのパス (タスクファイルについては次の節で説明します) | 11 | | preset | プリセットのパッケージ名、もしくは、プリセットを指定するオブジェクト | 12 | | preset.name | プリセットのパッケージ名 | 13 | | preset.options | プリセットのオプション | 14 | | rules | ソースコードをどのように変換するかを指定 | 15 | | dev | 開発サーバー関連のオプション (詳細は [Dev options](#dev-options)) | 16 | 17 | ## ルール 18 | 19 | _ルール_ によってソースコードをどのように変換するかを指定することができます。設定ファイルの `rules` はキーが変換するソースコードの拡張子と対応するオブジェクトです。例えば、`.js` ファイルを変換したいとき、`rules` オブジェクトに `js` プロパティを追加します。 20 | 21 | `rules` オブジェクトのそれぞれの値はオブジェクト、文字列、または、関数になります。文字列、関数が指定されたときは `task` として扱われます。 22 | 23 | | キー | 説明 | 24 | | --------- | --------------------------------------------------------------------------------------- | 25 | | task | 変換として適用されるタスク名、または、インラインタスク | 26 | | outputExt | 出力ファイルの拡張子。省略されたときは入力ファイルの拡張子と同じになる。 | 27 | | exclude | このルールを適用しないファイルの glob パターンの文字列、もしくは、その配列 | 28 | | progeny | ファイル形式に対応する [progeny の設定](https://github.com/es128/progeny#configuration) | 29 | | options | タスクのオプション (タスク関数の第 2 引数に渡されます) | 30 | 31 | ## プリセット 32 | 33 | Houl は npm で公開されている外部のプリセットを読み込めます。設定ファイルの `preset` フィールドを指定することで読み込めます。例えば、`houl-preset-foo` というプリセットを使いたいときは、そのパッケージ名を `preset` に書きます。 34 | 35 | ```json 36 | { 37 | "input": "./src", 38 | "output": "./dist", 39 | "preset": "houl-preset-foo" 40 | } 41 | ``` 42 | 43 | ### プリセットオプションの指定 44 | 45 | `preset` フィールドの `options` プロパティで任意のオプションの値をプリセットに渡すことができます。 46 | 47 | ```json 48 | { 49 | "input": "./src", 50 | "output": "./dist", 51 | "preset": { 52 | "name": "houl-preset-foo", 53 | "options": { 54 | "exclude": "**/_*/**" 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | 指定されたオプションはプリセットの設定ファイルが関数の形式で定義されていれば、その中で使用できます。 61 | 62 | ```js 63 | module.exports = function(options) { 64 | return { 65 | exclude: options.exclude, 66 | rules: { 67 | // ... 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ### プリセット設定の拡張 74 | 75 | 特有の要件に対応するため、既存のプリセットのルールを拡張したい場合があるかもしれません。そのような場合は、対応するルールに追加のオプションを指定するだけで良いです。 76 | 77 | 例えば、プリセットの設定が次のようなときを考えます。 78 | 79 | ```js 80 | { 81 | "rules": { 82 | "js": { 83 | "task": "script" 84 | } 85 | } 86 | } 87 | ``` 88 | 89 | また、ユーザーの設定が以下の通りだとします。 90 | 91 | ```js 92 | { 93 | "preset": "houl-preset-foo", 94 | "rules": { 95 | "js": { 96 | "exclude": "**/_*/**" 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | 上記は次の設定と同じ意味を持ちます。 103 | 104 | ```js 105 | { 106 | "rules": { 107 | "js": { 108 | "task": "script", 109 | "exclude": "**/_*/**" 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | プリセットをより柔軟に変更したいときは、`preset.modifyConfig` オプションを使えます。`modifyConfig` には第 1 引数に生のプリセットの設定オブジェクトが渡される関数を指定します。プリセットの設定をその関数内で変更したり、任意で新しい設定オブジェクトを返すことができます。 116 | 117 | ```js 118 | module.exports = { 119 | input: './src', 120 | output: './dist', 121 | preset: { 122 | name: 'houl-preset-foo', 123 | modifyConfig: config => { 124 | // プリセット内の `foo` タスクを削除 125 | // タスクを削除するには `null` を代入するのではなく 126 | // `delete` 文を使う必要があります 127 | delete config.rules.foo 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | ## 開発オプション 134 | 135 | `dev` フィールドには開発サーバー関連のオプションを渡せます。`dev` フィールドは次のようなプロパティを含むオブジェクトを期待します。 136 | 137 | | キー | 説明 | 138 | | -------- | ----------------------------------------------------------------------------------------------------------- | 139 | | proxy | [`node-http-proxy` のオプション](https://github.com/nodejitsu/node-http-proxy#options) 互換のプロキシ設定。 | 140 | | port | 開発サーバーのポート番号。`--port` CLI オプションと同じ。 | 141 | | basePath | 開発サーバーのベースとなるパス。`--base-path` CLI オプションと同じ。 | 142 | 143 | 下記は `proxy` 設定の例です。 144 | 145 | ```json 146 | { 147 | "dev": { 148 | "proxy": { 149 | "/foo": "http://foo.com/", 150 | "/bar": { 151 | "target": "https://bar.com/", 152 | "secure": true 153 | } 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | `proxy` オブジェクトのキーはどのパスに向けたリクエストをプロキシすべきかを表しています。上記の設定は開発サーバーへのリクエスト `/foo` を `http://foo.com/` にプロキシし、`/bar` を `https://bar.com/` にプロキシします。 160 | 161 | ## 設定例 162 | 163 | 設定ファイルの完全な例は以下のとおりです。 164 | 165 | ```json 166 | { 167 | "input": "./src", 168 | "output": "./dist", 169 | "exclude": ["**/_*", "**/private/**"], 170 | "taskFile": "./houl.task.js", 171 | "preset": "houl-preset-foo", 172 | "rules": { 173 | "js": { 174 | "task": "scripts", 175 | "exclude": "**/vendor/**", 176 | "progeny": { 177 | "extension": "es6" 178 | } 179 | }, 180 | "scss": { 181 | "task": "styles", 182 | "outputExt": "css", 183 | "options": { 184 | "fooValue": "foo" 185 | } 186 | } 187 | } 188 | } 189 | ``` 190 | -------------------------------------------------------------------------------- /docs/ja/guide/installation.md: -------------------------------------------------------------------------------- 1 | # インストール 2 | 3 | ```bash 4 | # npm 5 | $ npm install --global houl 6 | 7 | # yarn 8 | $ yarn global add houl 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/ja/guide/task.md: -------------------------------------------------------------------------------- 1 | # タスクファイル 2 | 3 | タスクファイルにはソースコードが Houl によってどのように変換されるかを定義します。特筆すべき点は、タスクファイルは任意の [Gulp](http://gulpjs.com/) プラグインと互換性を持っているという点です。これは Houl で潤沢な Gulp のエコシステムを活用することができることを意味します。 4 | 5 | タスクファイルはいくつかの関数をエクスポートする `.js` ファイルです。エクポートされる関数はソースコードが送られるストリームを受け取るので、それをパイプして変換を行ったストリームを返す必要があります。第 2 引数には設定ファイルの各ルールに指定されたオプションの値が渡されます。任意の Gulp プラグインを使ってストリームをパイプできます。 6 | 7 | ```javascript 8 | const babel = require('gulp-babel') 9 | const sass = require('gulp-sass') 10 | 11 | exports.scripts = stream => { 12 | return stream.pipe(babel()) 13 | } 14 | 15 | exports.styles = (stream, options) => { 16 | return stream.pipe(sass(options.sass)) 17 | } 18 | ``` 19 | 20 | エクスポートする名前は設定ファイル内で使われます (例えば、`exports.scripts` と書いたら、設定ファイル内では `"scripts"` タスクとして使用することができます)。 21 | 22 | ## インラインタスク 23 | 24 | タスクの関数を設定ファイル内に直接書くこともできます。ルールオブジェクト内の `task` オプションはタスク関数も受け取れます: 25 | 26 | ```javascript 27 | const babel = require('gulp-babel') 28 | const sass = require('gulp-sass') 29 | 30 | module.exports = options => { 31 | return { 32 | rules: { 33 | js: { 34 | task: stream => { 35 | return stream.pipe(babel()) 36 | } 37 | }, 38 | scss: { 39 | task: stream => { 40 | return stream.pipe(sass(options.sass)) 41 | }, 42 | outputExt: 'css' 43 | } 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ## タスクヘルパー 50 | 51 | 環境特有の変換を行いたいときは `dev` や `prod` ヘルパーを使えます。 52 | 53 | ```javascript 54 | const { dev, prod } = require('houl') 55 | const babel = require('gulp-babel') 56 | const sourcemaps = require('gulp-sourcemaps') 57 | const uglify = require('gulp-uglify') 58 | 59 | exports.scripts = stream => { 60 | return stream 61 | .pipe(dev(soucemaps.init())) // 開発モードのみソースマップを生成 62 | .pipe(babel()) 63 | .pipe(prod(uglify())) // プロダクションではミニファイする 64 | .pipe(dev(soucemaps.write())) 65 | } 66 | ``` 67 | 68 | `build` コマンドに `--production` フラグを渡すとプロダクションモードを有効にできます。 69 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /example/houl.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "src", 3 | "exclude": "**/_*", 4 | "output": "dist", 5 | "taskFile": "houl.task.js", 6 | "rules": { 7 | "js": { 8 | "task": "js", 9 | "exclude": "**/vendor/**" 10 | }, 11 | "scss": { 12 | "task": "sass", 13 | "outputExt": "css" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/houl.task.js: -------------------------------------------------------------------------------- 1 | const { dev, prod } = require('../lib/api') 2 | const buble = require('gulp-buble') 3 | const sass = require('gulp-sass') 4 | const sourcemaps = require('gulp-sourcemaps') 5 | const uglify = require('gulp-uglify') 6 | 7 | exports.js = stream => { 8 | return stream 9 | .pipe(dev(sourcemaps.init())) 10 | .pipe(buble()) 11 | .pipe(prod(uglify())) 12 | .pipe(dev(sourcemaps.write())) 13 | } 14 | 15 | exports.sass = stream => { 16 | return stream 17 | .pipe(dev(sourcemaps.init())) 18 | .pipe(sass()) 19 | .pipe(dev(sourcemaps.write())) 20 | } 21 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "houl-example", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "gulp-buble": "^0.8.0", 7 | "gulp-sass": "^3.1.0", 8 | "gulp-sourcemaps": "^2.4.1", 9 | "gulp-uglify": "^2.0.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/src/css/index.scss: -------------------------------------------------------------------------------- 1 | @import 'internal/variables'; 2 | 3 | .foo { 4 | .bar { 5 | color: $color; 6 | } 7 | } 8 | 9 | h1 { 10 | color: blue; 11 | } 12 | -------------------------------------------------------------------------------- /example/src/css/internal/_variables.scss: -------------------------------------------------------------------------------- 1 | $color: red 2 | -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Houl Example 6 | 7 | 8 | 9 |

Houl

10 |

Gulp compatible build tool targeted for huge static sites

11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/src/js/index.js: -------------------------------------------------------------------------------- 1 | const foo = 'foobar' 2 | console.log(foo) 3 | -------------------------------------------------------------------------------- /example/src/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const lib = 'library' 2 | console.log(lib) 3 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | const { PassThrough } = require('stream') 2 | const { build } = require('./build') 3 | const { dev: startDevServer } = require('./dev') 4 | const { watch } = require('./watch') 5 | 6 | function isDev() { 7 | return process.env.NODE_ENV !== 'production' 8 | } 9 | 10 | function noop() { 11 | return new PassThrough({ objectMode: true }) 12 | } 13 | 14 | function dev(stream) { 15 | return isDev() ? stream : noop() 16 | } 17 | 18 | function prod(stream) { 19 | return isDev() ? noop() : stream 20 | } 21 | 22 | exports.dev = dev 23 | exports.prod = prod 24 | exports.build = build 25 | exports.startDevServer = startDevServer 26 | exports.watch = watch 27 | -------------------------------------------------------------------------------- /lib/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const vfs = require('vinyl-fs') 4 | const hashSum = require('hash-sum') 5 | const loadConfig = require('./config').loadConfig 6 | const Cache = require('./cache') 7 | const DepResolver = require('./dep-resolver') 8 | const BuildLogger = require('./loggers/build-logger') 9 | const taskStream = require('./task-stream') 10 | const cacheStream = require('./cache-stream') 11 | const util = require('./util') 12 | 13 | /** 14 | * The top level function to build sources. 15 | * The 1st parameter can accept the following options: 16 | * 17 | * config - Path to a houl config file 18 | * cache - Path to a houl cache file 19 | * production - Enable production mode 20 | * dot - Include dot files in output 21 | * filter - Glob pattern for filtering input files 22 | * 23 | * The 2nd parameter is meant to be used for internal, 24 | * so the users should not use it. 25 | * 26 | * The return value is a Promise object which will be 27 | * resolved when the build is exit successfully. 28 | */ 29 | function build(options, debug = {}) { 30 | if (options.production) { 31 | process.env.NODE_ENV = 'production' 32 | } 33 | 34 | const config = loadConfig(options.config).extend({ 35 | filter: options.filter 36 | }) 37 | 38 | // Logger 39 | const logger = new BuildLogger(config, { 40 | console: debug.console 41 | }) 42 | logger.start() 43 | 44 | // Process all files in input directory 45 | let stream = vfs.src(config.vinylInput, { 46 | base: config.input, 47 | nodir: true, 48 | dot: options.dot 49 | }) 50 | 51 | if (options.cache) { 52 | const cacheData = util.readFileSync(options.cache) 53 | 54 | const cache = new Cache(hashSum) 55 | 56 | const depResolver = DepResolver.create(config) 57 | 58 | if (cacheData) { 59 | const json = JSON.parse(cacheData) 60 | 61 | // Restore cache data 62 | cache.deserialize(json.cache) 63 | 64 | // Restore deps data 65 | depResolver.deserialize(json.deps) 66 | } 67 | 68 | stream = stream.pipe(cacheStream(cache, depResolver, util.readFileSync)) 69 | 70 | // Save cache 71 | stream.on('end', () => { 72 | const serialized = JSON.stringify({ 73 | cache: cache.serialize(), 74 | deps: depResolver.serialize() 75 | }) 76 | util.writeFileSync(options.cache, serialized) 77 | }) 78 | } 79 | 80 | return new Promise((resolve, reject) => { 81 | // Transform inputs with rules 82 | stream = stream 83 | .pipe(taskStream(config)) 84 | // This line does not work yet. 85 | // We must patch .pipe method and handle 86 | // all error events for each stream. 87 | .on('error', err => { 88 | logger.error(err) 89 | reject(err) 90 | }) 91 | 92 | // Output 93 | return stream.pipe(vfs.dest(config.output)).on('finish', () => { 94 | logger.finish() 95 | resolve() 96 | }) 97 | }) 98 | } 99 | 100 | exports.build = build 101 | -------------------------------------------------------------------------------- /lib/cache-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Transform = require('stream').Transform 4 | const DepCache = require('./dep-cache') 5 | 6 | module.exports = function(cache, depResolver, readFile) { 7 | const contentsMap = new Map() 8 | const depCache = new DepCache(cache, depResolver, getContent) 9 | 10 | function getContent(fileName) { 11 | // If contentsMap has the previously loaded content, just use it 12 | const content = contentsMap.get(fileName) 13 | if (content) { 14 | return content 15 | } 16 | 17 | const loaded = readFile(fileName) 18 | contentsMap.set(fileName, loaded) 19 | return loaded 20 | } 21 | 22 | const stream = new Transform({ 23 | objectMode: true, 24 | transform(file, encoding, callback) { 25 | const source = file.contents.toString() 26 | 27 | contentsMap.set(file.path, source) 28 | if (!depCache.test(file.path, source)) { 29 | this.push(file) 30 | } 31 | 32 | callback() 33 | } 34 | }) 35 | 36 | // Resolve all previously loaded files deps and register them to cache. 37 | // We need lazily update the cache because it could block build targets 38 | // if the nested deps are registered before building them. 39 | stream.on('finish', () => { 40 | for (const entry of contentsMap.entries()) { 41 | depCache.register(entry[0], entry[1]) 42 | } 43 | }) 44 | 45 | return stream 46 | } 47 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const util = require('./util') 5 | 6 | class Cache { 7 | constructor(hash) { 8 | this.map = {} 9 | this.hash = hash || util.identity 10 | } 11 | 12 | get(filename) { 13 | assert(typeof filename === 'string', 'File name must be a string') 14 | const item = this.map[filename] 15 | return item && item.data 16 | } 17 | 18 | register(filename, source, data) { 19 | assert(typeof filename === 'string', 'File name must be a string') 20 | let item = this.map[filename] 21 | if (!item) { 22 | item = this.map[filename] = {} 23 | } 24 | 25 | item.hash = this.hash(source) 26 | item.data = data === undefined ? item.data : data 27 | } 28 | 29 | clear(filename) { 30 | assert(typeof filename === 'string', 'File name must be a string') 31 | delete this.map[filename] 32 | } 33 | 34 | test(filename, source) { 35 | assert(typeof filename === 'string', 'File name must be a string') 36 | const item = this.map[filename] 37 | return !!item && item.hash === this.hash(source) 38 | } 39 | 40 | /** 41 | * We do not include cache data into serialized object 42 | */ 43 | serialize() { 44 | return util.mapValues(this.map, item => item.hash) 45 | } 46 | 47 | deserialize(map) { 48 | this.map = util.mapValues(map, hash => { 49 | return { hash } 50 | }) 51 | } 52 | } 53 | module.exports = Cache 54 | -------------------------------------------------------------------------------- /lib/cli/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const findConfig = require('../config').findConfig 5 | const { build } = require('../build') 6 | const util = require('../util') 7 | 8 | exports.builder = { 9 | config: { 10 | alias: 'c', 11 | describe: 'Path to a houl config file' 12 | }, 13 | cache: { 14 | describe: 'Path to a houl cache file' 15 | }, 16 | production: { 17 | describe: 'Enable production mode', 18 | boolean: true 19 | }, 20 | dot: { 21 | describe: 'Include dot files in output', 22 | boolean: true 23 | }, 24 | filter: { 25 | describe: 'Glob pattern for filtering input files' 26 | } 27 | } 28 | 29 | exports.handler = (argv, debug = {}) => { 30 | const configPath = argv.config || findConfig(process.cwd()) 31 | assert(configPath, 'Config file is not found') 32 | 33 | const options = util.merge(argv, { config: configPath }) 34 | return build(options, debug) 35 | } 36 | -------------------------------------------------------------------------------- /lib/cli/dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const findConfig = require('../config').findConfig 5 | const { dev } = require('../dev') 6 | const util = require('../util') 7 | 8 | exports.builder = { 9 | config: { 10 | alias: 'c', 11 | describe: 'Path to a houl config file' 12 | }, 13 | port: { 14 | alias: 'p', 15 | describe: 'Port number of dev server', 16 | number: true 17 | }, 18 | 'base-path': { 19 | describe: 'Base path of dev server' 20 | } 21 | } 22 | 23 | exports.handler = (argv, debug = {}) => { 24 | const configPath = argv.config || findConfig(process.cwd()) 25 | assert(configPath, 'Config file is not found') 26 | assert(!Number.isNaN(argv.port), '--port should be a number') 27 | 28 | const options = util.merge(argv, { 29 | config: configPath, 30 | basePath: argv['base-path'] 31 | }) 32 | return dev(options, debug) 33 | } 34 | -------------------------------------------------------------------------------- /lib/cli/watch.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const findConfig = require('../config').findConfig 5 | const { watch } = require('../watch') 6 | const util = require('../util') 7 | 8 | exports.builder = { 9 | config: { 10 | alias: 'c', 11 | describe: 'Path to a houl config file' 12 | }, 13 | cache: { 14 | describe: 'Path to a houl cache file' 15 | }, 16 | dot: { 17 | describe: 'Include dot files in output', 18 | boolean: true 19 | } 20 | } 21 | 22 | exports.handler = (argv, debug) => { 23 | const configPath = argv.config || findConfig(process.cwd()) 24 | assert(configPath, 'Config file is not found') 25 | 26 | const options = util.merge(argv, { config: configPath }) 27 | return watch(options, debug) 28 | } 29 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const GREEN = '\u001b[32m' 4 | const YELLOW = '\u001b[33m' 5 | const CYAN = '\u001b[36m' 6 | 7 | const RESET = '\u001b[0m' 8 | 9 | function green(str) { 10 | return GREEN + str + RESET 11 | } 12 | 13 | function yellow(str) { 14 | return YELLOW + str + RESET 15 | } 16 | 17 | function cyan(str) { 18 | return CYAN + str + RESET 19 | } 20 | 21 | exports.green = green 22 | exports.yellow = yellow 23 | exports.cyan = cyan 24 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const path = require('path') 5 | const fs = require('fs') 6 | const Config = require('./models/config') 7 | const util = require('./util') 8 | 9 | function loadConfig(configPath, configOptions, transform = util.identity) { 10 | const options = {} 11 | const base = (options.base = path.dirname(configPath)) 12 | 13 | const configOrFn = loadConfigFile(configPath) 14 | 15 | // Get a config object based on configOptions if it is a function 16 | const rawConfig = 17 | typeof configOrFn === 'function' 18 | ? configOrFn(configOptions || {}) 19 | : configOrFn 20 | 21 | // If it is a preset object, may be transformed via `preset.modifyConfig` 22 | const config = transform(rawConfig) || rawConfig 23 | 24 | // Unify the `preset` option into the object style 25 | const preset = 26 | typeof config.preset === 'string' ? { name: config.preset } : config.preset 27 | 28 | if (preset && preset.name) { 29 | const presetPath = resolvePresetPath(preset.name, base) 30 | options.preset = loadConfig(presetPath, preset.options, preset.modifyConfig) 31 | } 32 | 33 | const tasks = config.taskFile 34 | ? require(path.resolve(base, config.taskFile)) 35 | : {} 36 | 37 | return Config.create(config, tasks, options) 38 | } 39 | exports.loadConfig = loadConfig 40 | 41 | function loadConfigFile(configPath) { 42 | const ext = path.extname(configPath) 43 | 44 | assert( 45 | ext === '.js' || ext === '.json', 46 | path.basename(configPath) + ' is non-supported file format.' 47 | ) 48 | 49 | try { 50 | return require(path.resolve(configPath)) 51 | } catch (e) { 52 | if (e.code === 'MODULE_NOT_FOUND') { 53 | throw new Error(`${configPath} is not found.`) 54 | } else { 55 | throw e 56 | } 57 | } 58 | } 59 | 60 | function resolvePresetPath(preset, base) { 61 | if (!util.isLocalPath(preset)) { 62 | return require.resolve(preset) 63 | } 64 | return require.resolve(path.resolve(base, preset)) 65 | } 66 | 67 | function findConfig(dirname, exists) { 68 | exists = exists || fs.existsSync 69 | 70 | const jsConfig = path.join(dirname, 'houl.config.js') 71 | if (exists(jsConfig)) { 72 | return jsConfig 73 | } 74 | 75 | const jsonConfig = path.join(dirname, 'houl.config.json') 76 | if (exists(jsonConfig)) { 77 | return jsonConfig 78 | } 79 | 80 | const parent = path.dirname(dirname) 81 | if (parent === dirname) { 82 | return null 83 | } 84 | 85 | return findConfig(parent, exists) 86 | } 87 | exports.findConfig = findConfig 88 | -------------------------------------------------------------------------------- /lib/dep-cache.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Similar with Cache but considering files dependencies. 5 | */ 6 | class DepCache { 7 | constructor(cache, depResolver, readFile) { 8 | this.cache = cache 9 | this.depResolver = depResolver 10 | this.readFile = readFile 11 | } 12 | 13 | get(filename) { 14 | return this.cache.get(filename) 15 | } 16 | 17 | register(filename, source, data) { 18 | const footprints = new Set() 19 | 20 | const loop = (filename, source) => { 21 | if (footprints.has(filename)) { 22 | return 23 | } 24 | footprints.add(filename) 25 | 26 | // #26 27 | // When `source` is empty value, 28 | // the file is not exists so we must clear the cache. 29 | if (source == null) { 30 | this.clear(filename) 31 | return 32 | } 33 | 34 | this.cache.register(filename, source, data) 35 | this.depResolver.register(filename, source) 36 | 37 | this.depResolver.getOutDeps(filename).forEach(dep => { 38 | const depSource = this.readFile(dep) 39 | if (this.cache.test(dep, depSource)) { 40 | return 41 | } 42 | 43 | loop(dep, depSource) 44 | }) 45 | } 46 | 47 | loop(filename, source, data) 48 | } 49 | 50 | clear(filename) { 51 | // Only clear the target file cache because we cannot be sure 52 | // the deps are really no longer useless for now. 53 | // i.e. The user may refer them directly via `test` method. 54 | this.cache.clear(filename) 55 | this.depResolver.clear(filename) 56 | } 57 | 58 | /** 59 | * Test the given file has the same contents. 60 | * It also considers the dependencies. That means it is treated as 61 | * updated when one of the dependenies is changed even if the target 62 | * file itself is not. 63 | */ 64 | test(fileName, source) { 65 | // If original source is updated, it should not be the same. 66 | if (!this.cache.test(fileName, source)) { 67 | return false 68 | } 69 | 70 | const deps = this.depResolver.getOutDeps(fileName) 71 | 72 | // Loop through deps to compare its contents 73 | let isUpdate = false 74 | for (const fileName of deps) { 75 | const contents = this.readFile(fileName) 76 | 77 | // What should we do if possible deps are not found? 78 | // For now, treat it as updated contents 79 | // so that transformers can handle the error 80 | if (contents == null) { 81 | isUpdate = true 82 | } else { 83 | isUpdate = isUpdate || !this.cache.test(fileName, contents) 84 | } 85 | } 86 | 87 | return !isUpdate 88 | } 89 | } 90 | module.exports = DepCache 91 | -------------------------------------------------------------------------------- /lib/dep-resolver.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const path = require('path') 5 | const progeny = require('progeny') 6 | const util = require('./util') 7 | 8 | class DepResolver { 9 | constructor(resolveDeps) { 10 | // `progeny` - (filePath, fileContent) => filePath[] 11 | this.resolveDeps = resolveDeps 12 | 13 | this.files = new Map() 14 | } 15 | 16 | register(fileName, content) { 17 | const file = this._getFile(fileName) 18 | const newOut = this.resolveDeps(fileName, content) 19 | 20 | this._cleanUpOutDeps(file) 21 | file.outDeps = newOut 22 | this._registerOutDeps(file) 23 | 24 | this._setFile(fileName, file) 25 | } 26 | 27 | clear(fileName) { 28 | const file = this._getFile(fileName) 29 | this._cleanUpOutDeps(file) 30 | this._deleteFile(fileName) 31 | } 32 | 33 | getInDeps(fileName) { 34 | const origin = this._getFile(fileName) 35 | return origin.inDeps.reduce(this._resolveNestedDeps(origin, 'inDeps'), []) 36 | } 37 | 38 | getOutDeps(fileName) { 39 | const origin = this._getFile(fileName) 40 | return origin.outDeps.reduce(this._resolveNestedDeps(origin, 'outDeps'), []) 41 | } 42 | 43 | serialize() { 44 | const map = {} 45 | 46 | for (const item of this.files.values()) { 47 | map[item.fileName] = item.outDeps 48 | } 49 | 50 | return map 51 | } 52 | 53 | deserialize(map) { 54 | this.files.clear() 55 | 56 | Object.keys(map).forEach(key => { 57 | const file = this._getFile(key) 58 | file.outDeps = map[key] 59 | 60 | this._registerOutDeps(file) 61 | this._setFile(key, file) 62 | }) 63 | } 64 | 65 | _getFile(fileName) { 66 | const file = this.files.get(fileName) 67 | 68 | if (file) return file 69 | 70 | const newFile = { 71 | fileName, 72 | outDeps: [], 73 | inDeps: [] 74 | } 75 | this._setFile(fileName, newFile) 76 | return newFile 77 | } 78 | 79 | _setFile(fileName, file) { 80 | this.files.set(fileName, file) 81 | } 82 | 83 | _deleteFile(fileName) { 84 | this.files.delete(fileName) 85 | } 86 | 87 | _registerOutDeps(file) { 88 | file.outDeps.forEach(name => { 89 | const dep = this._getFile(name) 90 | dep.inDeps.push(file.fileName) 91 | this._setFile(dep.fileName, dep) 92 | }) 93 | } 94 | 95 | _cleanUpOutDeps(file) { 96 | file.outDeps.forEach(name => { 97 | const dep = this._getFile(name) 98 | dep.inDeps = dep.inDeps.filter(inDep => { 99 | return inDep !== file.fileName 100 | }) 101 | this._setFile(dep.fileName, dep) 102 | }) 103 | } 104 | 105 | _resolveNestedDeps(file, depDirection) { 106 | assert(depDirection === 'outDeps' || depDirection === 'inDeps') 107 | 108 | const footprints = new Set() 109 | footprints.add(file.fileName) 110 | 111 | const resolveImpl = (acc, fileName) => { 112 | const file = this._getFile(fileName) 113 | 114 | // detect circlar deps 115 | if (footprints.has(file.fileName)) { 116 | return acc 117 | } 118 | 119 | footprints.add(file.fileName) 120 | 121 | return file[depDirection].reduce(resolveImpl, acc.concat([file.fileName])) 122 | } 123 | 124 | return resolveImpl 125 | } 126 | 127 | static create(config) { 128 | const progenyOptions = util.mapValues(config.rules, rule => rule.progeny) 129 | return new DepResolver((fileName, contents) => { 130 | const ext = path.extname(fileName).slice(1) 131 | return progeny.Sync(progenyOptions[ext])(fileName, contents) 132 | }) 133 | } 134 | } 135 | module.exports = DepResolver 136 | -------------------------------------------------------------------------------- /lib/dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | const externalIp = require('./util').getIp() 5 | const loadConfig = require('./config').loadConfig 6 | const Cache = require('./cache') 7 | const DepResolver = require('./dep-resolver') 8 | const DepCache = require('./dep-cache') 9 | const create = require('./externals/browser-sync') 10 | const createWatcher = require('./externals/watcher') 11 | const DevLogger = require('./loggers/dev-logger') 12 | const util = require('./util') 13 | 14 | /** 15 | * The top level function for the dev server. 16 | * The 1st parameter can accept the following options: 17 | * 18 | * config - Path to a houl config file 19 | * port - Port number of dev server 20 | * basePath - Base path of dev server 21 | * 22 | * The 2nd parameter and return value are meant to be used for internal, 23 | * so the users should not use them. 24 | */ 25 | function dev(options, debug = {}) { 26 | const config = loadConfig(options.config).extend({ 27 | port: options.port, 28 | basePath: options.basePath 29 | }) 30 | 31 | // Logger 32 | const logger = new DevLogger(config, { 33 | console: debug.console 34 | }) 35 | 36 | const cache = new Cache() 37 | // The state of DepResolver is shared between browser-sync and watcher 38 | const resolver = DepResolver.create(config) 39 | const depCache = new DepCache(cache, resolver, util.readFileSync) 40 | 41 | const bs = create( 42 | config, 43 | { 44 | logLevel: 'silent', 45 | middleware: logMiddleware, 46 | open: !options._debug // Internal 47 | }, 48 | depCache 49 | ) 50 | 51 | bs.emitter.on('init', () => { 52 | logger.startDevServer(config.port, externalIp) 53 | }) 54 | 55 | const watcher = createWatcher(config, resolver, (name, files, origin) => { 56 | if (name === 'add') { 57 | logger.addFile(origin) 58 | } else if (name === 'change') { 59 | logger.updateFile(origin) 60 | } 61 | bs.reload(files.map(resolveOutput)) 62 | }) 63 | 64 | return { bs, watcher } 65 | 66 | function resolveOutput(inputName) { 67 | const rule = config.findRuleByInput(inputName) 68 | 69 | if (!rule) { 70 | return inputName 71 | } else { 72 | return rule.getOutputPath(inputName) 73 | } 74 | } 75 | 76 | function logMiddleware(req, res, next) { 77 | const parsedPath = url.parse(req.url).pathname 78 | logger.getFile(parsedPath) 79 | next() 80 | } 81 | } 82 | 83 | exports.dev = dev 84 | -------------------------------------------------------------------------------- /lib/externals/browser-sync.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const url = require('url') 6 | const vfs = require('vinyl-fs') 7 | const { Readable, Writable } = require('stream') 8 | const browserSync = require('browser-sync') 9 | const proxy = require('http-proxy-middleware') 10 | const mime = require('mime') 11 | const util = require('../util') 12 | 13 | const defaultBsOptions = { 14 | ui: false, 15 | ghostMode: false, 16 | notify: false 17 | } 18 | 19 | module.exports = (config, bsOptions, depCache) => { 20 | const bs = browserSync.create() 21 | 22 | bsOptions = util.merge(defaultBsOptions, bsOptions) 23 | bsOptions = util.merge(bsOptions, { 24 | port: config.port, 25 | startPath: config.basePath 26 | }) 27 | 28 | bsOptions.server = config.output 29 | injectMiddleware(bsOptions, transformer(config, depCache)) 30 | config.proxy.forEach(p => { 31 | injectMiddleware(bsOptions, proxy(p.context, p.config)) 32 | }) 33 | 34 | bs.init(bsOptions) 35 | 36 | return bs 37 | } 38 | 39 | function injectMiddleware(options, middleware) { 40 | if (Array.isArray(options.middleware)) { 41 | options.middleware.push(middleware) 42 | return 43 | } 44 | 45 | if (options.middleware) { 46 | options.middleware = [options.middleware, middleware] 47 | return 48 | } 49 | 50 | options.middleware = [middleware] 51 | } 52 | 53 | function transformer(config, depCache) { 54 | const basePath = config.basePath 55 | return (req, res, next) => { 56 | const parsedPath = url.parse(req.url).pathname 57 | const reqPath = normalizePath(parsedPath) 58 | 59 | const outputPath = resolveBase(basePath, reqPath) 60 | if (outputPath === null) { 61 | return next() 62 | } 63 | 64 | const rule = config.findRuleByOutput(outputPath, inputPath => { 65 | return fs.existsSync(path.join(config.input, inputPath)) 66 | }) 67 | 68 | // If any rules are not matched, leave it to browsersync 69 | if (!rule) return next() 70 | 71 | const inputPath = path.join(config.input, rule.getInputPath(outputPath)) 72 | 73 | // If Input file is excluded by config, leave it to browsersync 74 | if (config.isExclude(inputPath)) return next() 75 | 76 | // If it is directory, redirect to path that added trailing slash 77 | // There should not be not found error 78 | // since it is already checked in previous process 79 | if (fs.statSync(inputPath).isDirectory()) { 80 | return redirect(res, reqPath + '/') 81 | } 82 | 83 | const responseFile = new Writable({ 84 | objectMode: true, 85 | 86 | write(file, encoding, cb) { 87 | const response = contents => { 88 | res.setHeader('Content-Type', mime.getType(outputPath)) 89 | res.end(contents) 90 | } 91 | 92 | // If the inputPath hits the cache, just use cached output 93 | const contentsStr = String(file.contents) 94 | if (depCache.test(file.path, contentsStr)) { 95 | const contents = depCache.get(file.path) 96 | response(contents) 97 | cb(null) 98 | return 99 | } 100 | 101 | const inputProxy = new Readable({ 102 | objectMode: true, 103 | 104 | read() { 105 | this.push(file) 106 | this.push(null) 107 | } 108 | }) 109 | 110 | rule 111 | .task(inputProxy) 112 | .on('data', transformed => { 113 | // Register the transformed contents into cache 114 | depCache.register(file.path, contentsStr, transformed.contents) 115 | 116 | response(transformed.contents) 117 | }) 118 | .on('end', cb) 119 | } 120 | }) 121 | 122 | vfs.src(inputPath).pipe(responseFile) 123 | } 124 | } 125 | 126 | function redirect(res, pathname) { 127 | res.statusCode = 301 128 | res.setHeader('Location', pathname) 129 | res.end() 130 | } 131 | 132 | function resolveBase(base, target) { 133 | function loop(base, target) { 134 | const bh = base[0] 135 | if (bh === undefined) { 136 | return '/' + target.join('/') 137 | } 138 | 139 | const th = target[0] 140 | if (bh !== th) { 141 | return null 142 | } 143 | 144 | return loop(base.slice(1), target.slice(1)) 145 | } 146 | 147 | const baseList = base.split('/').filter(item => item !== '') 148 | const targetList = trimHead(target.split('/')) 149 | return loop(baseList, targetList) 150 | } 151 | 152 | function normalizePath(pathname) { 153 | if (isDirectoryPath(pathname)) { 154 | pathname = path.join(pathname, 'index.html') 155 | } 156 | return pathname 157 | } 158 | 159 | function isDirectoryPath(pathname) { 160 | return /\/$/.test(pathname) 161 | } 162 | 163 | function trimHead(list) { 164 | return util.dropWhile(list, item => item === '') 165 | } 166 | -------------------------------------------------------------------------------- /lib/externals/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const chokidar = require('chokidar') 5 | 6 | module.exports = function createWatcher(config, depResolver, cb) { 7 | const options = { 8 | ignoreInitial: true, 9 | cwd: config.input 10 | } 11 | if (process.env.TEST) { 12 | // Disable fsevents on testing environment since it may cause clash sometimes. 13 | // See: https://github.com/paulmillr/chokidar/issues/612 14 | options.useFsEvents = false 15 | } 16 | 17 | return chokidar 18 | .watch(config.input, options) 19 | .on('add', file => watchHandler('add', file)) 20 | .on('change', file => watchHandler('change', file)) 21 | 22 | function watchHandler(name, pathname) { 23 | const fullPath = path.resolve(config.input, pathname) 24 | 25 | // Update deps 26 | depResolver.register(fullPath) 27 | 28 | // Resolve depended files 29 | const dirtyFiles = [fullPath].concat(depResolver.getInDeps(fullPath)) 30 | 31 | cb(name, dirtyFiles, fullPath) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/loggers/build-logger.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const path = require('path') 5 | const util = require('../util') 6 | 7 | class BuildLogger { 8 | constructor(config, options) { 9 | options = options || {} 10 | 11 | this.errorCount = 0 12 | this.config = config 13 | this.console = options.console || console 14 | this.now = options.now || Date.now 15 | this.startedTime = null 16 | } 17 | 18 | start() { 19 | const input = normalize(this.config.base, this.config.input) 20 | const output = normalize(this.config.base, this.config.output) 21 | 22 | this.startedTime = this.now() 23 | 24 | this.console.log(`Building ${input} -> ${output}`) 25 | } 26 | 27 | error(err) { 28 | assert(this.startedTime !== null) 29 | 30 | this.console.error(err.message) 31 | this.errorCount += 1 32 | } 33 | 34 | finish() { 35 | assert(this.startedTime !== null) 36 | 37 | if (this.errorCount > 0) { 38 | this.console.log(`Finished with ${this.errorCount} error(s)`) 39 | return 40 | } 41 | 42 | const time = this.now() - this.startedTime 43 | this.console.log(`Finished in ${prittyTime(time)}`) 44 | } 45 | } 46 | module.exports = BuildLogger 47 | 48 | function normalize(base, pathname) { 49 | return util.normalizePath(path.relative(base, pathname)) 50 | } 51 | 52 | function prittyTime(msec) { 53 | if (msec < 1000 * 0.1) { 54 | return trunc(msec) + 'ms' 55 | } else if (msec < 1000 * 1000 * 0.1) { 56 | return trunc(msec / 1000) + 's' 57 | } else { 58 | return trunc(msec / (60 * 1000)) + 'm' 59 | } 60 | } 61 | 62 | function trunc(n) { 63 | return Math.floor(n * 100) / 100 64 | } 65 | -------------------------------------------------------------------------------- /lib/loggers/dev-logger.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const color = require('../color') 5 | 6 | class DevLogger { 7 | constructor(config, options) { 8 | options = options || {} 9 | 10 | this.config = config 11 | this.console = options.console || console 12 | 13 | this.modPath = pathName => { 14 | return '/' + path.relative(this.config.input, pathName) 15 | } 16 | } 17 | 18 | startDevServer(port, externalIp) { 19 | this.console.log( 20 | `Houl dev server is running at:\nLocal: http://localhost:${port}` 21 | ) 22 | externalIp.forEach(ip => { 23 | this.console.log(`External: http://${ip}:${port}`) 24 | }) 25 | } 26 | 27 | addFile(source) { 28 | this._write(color.yellow('ADDED'), this.modPath(source)) 29 | } 30 | 31 | updateFile(source) { 32 | this._write(color.yellow('UPDATED'), this.modPath(source)) 33 | } 34 | 35 | getFile(pathname) { 36 | this._write(color.green('GET'), pathname) 37 | } 38 | 39 | _write(label, text) { 40 | this.console.log(label + ' ' + text) 41 | } 42 | } 43 | module.exports = DevLogger 44 | -------------------------------------------------------------------------------- /lib/loggers/watch-logger.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const color = require('../color') 5 | 6 | class WatchLogger { 7 | constructor(config, options) { 8 | this.config = config 9 | this.console = options.console || console 10 | 11 | this.modPath = pathName => { 12 | return '/' + path.relative(this.config.base, pathName) 13 | } 14 | } 15 | 16 | startWatching() { 17 | const source = path.relative(this.config.base, this.config.input) + '/' 18 | this.console.log(`Houl is watching the source directory: ${source}`) 19 | } 20 | 21 | addFile(source) { 22 | this._write(color.yellow('ADDED'), this.modPath(source)) 23 | } 24 | 25 | updateFile(source) { 26 | this._write(color.yellow('UPDATED'), this.modPath(source)) 27 | } 28 | 29 | writeFile(dest) { 30 | this._write(color.cyan('WROTE'), this.modPath(dest)) 31 | } 32 | 33 | _write(label, text) { 34 | this.console.log(label + ' ' + text) 35 | } 36 | } 37 | module.exports = WatchLogger 38 | -------------------------------------------------------------------------------- /lib/models/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const minimatch = require('minimatch') 5 | const Rule = require('./rule') 6 | const util = require('../util') 7 | 8 | module.exports = class Config { 9 | constructor(config) { 10 | this.base = config.base 11 | this.input = config.input 12 | this.output = config.output 13 | this.exclude = config.exclude || [] 14 | this.rules = config.rules || {} 15 | this.proxy = config.proxy || [] 16 | this.port = config.port || 3000 17 | this.basePath = config.basePath || '/' 18 | this.filter = config.filter || '**/*' 19 | } 20 | 21 | get vinylInput() { 22 | const res = [path.join(this.input, this.filter)] 23 | 24 | this.exclude.forEach(exclude => { 25 | res.push('!' + exclude) 26 | }) 27 | 28 | return res 29 | } 30 | 31 | /** 32 | * Create a new config model based on 33 | * this config model and the provided argument 34 | */ 35 | extend(config) { 36 | config = util.filterProps(config, value => value != null) 37 | return new Config(Object.assign({}, this, config)) 38 | } 39 | 40 | /** 41 | * Check the pathname matches `exclude` pattern or not. 42 | */ 43 | isExclude(pathname) { 44 | if (path.isAbsolute(pathname)) { 45 | pathname = path.relative(this.input, pathname) 46 | } 47 | 48 | return this.exclude.reduce((acc, exclude) => { 49 | return acc || minimatch(pathname, exclude) 50 | }, false) 51 | } 52 | 53 | findRuleByInput(inputName) { 54 | const ext = path.extname(inputName).slice(1) 55 | const rule = this.rules[ext] 56 | 57 | if (!rule || rule.isExclude(inputName)) { 58 | return null 59 | } 60 | 61 | return rule 62 | } 63 | 64 | /** 65 | * Detect cooresponding rule from output path 66 | * It is not deterministic unlike findRuleByInput, 67 | * we query whether the input file is exists 68 | * to determine what rule we should select. 69 | * If a possible input file path is not found, we skip the rule. 70 | */ 71 | findRuleByOutput(outputName, exists) { 72 | const ext = path.extname(outputName).slice(1) 73 | const rules = Object.keys(this.rules) 74 | .map(key => this.rules[key]) 75 | .concat(Rule.empty) 76 | 77 | for (const rule of rules) { 78 | if (!rule.isEmpty && rule.outputExt !== ext) continue 79 | 80 | const inputName = rule.getInputPath(outputName) 81 | 82 | if (!exists(inputName)) continue 83 | 84 | if (rule.isExclude(inputName)) { 85 | continue 86 | } 87 | 88 | return rule 89 | } 90 | 91 | return null 92 | } 93 | 94 | /** 95 | * Builder function for Config model 96 | * options = { preset, base } 97 | */ 98 | static create(config, tasks, options) { 99 | options = options || {} 100 | 101 | const preset = options.preset 102 | const resolve = makeResolve(options.base || '') 103 | const dev = config.dev || {} 104 | 105 | return new Config({ 106 | base: options.base, 107 | 108 | // Resolve input/output paths 109 | input: typeof config.input === 'string' && resolve(config.input), 110 | output: typeof config.output === 'string' && resolve(config.output), 111 | 112 | // `exclude` option excludes matched files from `input` 113 | exclude: resolveExclude(config.exclude), 114 | 115 | // Resolve and merge rules by traversing preset 116 | rules: resolveRules(config.rules || {}, tasks, preset), 117 | 118 | // Resolve dev server configs 119 | proxy: resolveProxy(dev.proxy), 120 | port: dev.port, 121 | basePath: dev.basePath 122 | }) 123 | } 124 | } 125 | 126 | function resolveExclude(exclude) { 127 | if (!exclude) return [] 128 | 129 | return typeof exclude === 'string' ? [exclude] : exclude 130 | } 131 | 132 | function resolveRules(rules, tasks, preset) { 133 | rules = util.mapValues(rules, (rule, key) => { 134 | return Rule.create(rule, key, tasks, preset && preset.rules[key]) 135 | }) 136 | 137 | if (!preset) return rules 138 | 139 | return util.merge(preset.rules, rules) 140 | } 141 | 142 | function resolveProxy(proxy) { 143 | if (!proxy) return [] 144 | 145 | return Object.keys(proxy).map(context => { 146 | const config = 147 | typeof proxy[context] === 'string' 148 | ? { target: proxy[context] } 149 | : proxy[context] 150 | 151 | return { context, config } 152 | }) 153 | } 154 | 155 | function makeResolve(base) { 156 | return pathname => { 157 | return path.resolve(base, pathname) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/models/rule.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const minimatch = require('minimatch') 5 | const util = require('../util') 6 | const createTask = require('./task') 7 | 8 | const emptyRule = new (class EmptyRule { 9 | constructor() { 10 | this.isEmpty = true 11 | this.taskName = null 12 | this.task = util.identity 13 | this.inputExt = null 14 | this.outputExt = null 15 | this.exclude = [] 16 | this.progeny = undefined 17 | } 18 | 19 | getInputPath(outputPath) { 20 | return outputPath 21 | } 22 | 23 | getOutputPath(inputPath) { 24 | return inputPath 25 | } 26 | 27 | isExclude() { 28 | return false 29 | } 30 | })() 31 | 32 | class Rule { 33 | constructor(rule, inputExt) { 34 | const rawTask = 35 | typeof rule === 'string' || typeof rule === 'function' ? rule : rule.task 36 | if (typeof rawTask === 'function') { 37 | this.taskName = '' 38 | this.task = rawTask 39 | } else { 40 | this.taskName = rawTask 41 | this.task = null 42 | } 43 | 44 | this.inputExt = inputExt 45 | this.outputExt = rule.outputExt || this.inputExt 46 | 47 | this.exclude = rule.exclude || [] 48 | if (typeof this.exclude === 'string') { 49 | this.exclude = [this.exclude] 50 | } 51 | 52 | this.progeny = rule.progeny 53 | if (this.progeny) { 54 | this.progeny.extension = inputExt 55 | } 56 | } 57 | 58 | getInputPath(outputPath) { 59 | const extRE = new RegExp(`\\.${this.outputExt}$`, 'i') 60 | return outputPath.replace(extRE, `.${this.inputExt}`) 61 | } 62 | 63 | getOutputPath(inputPath) { 64 | const extRE = new RegExp(`\\.${this.inputExt}$`, 'i') 65 | return inputPath.replace(extRE, `.${this.outputExt}`) 66 | } 67 | 68 | isExclude(inputPath) { 69 | return this.exclude.reduce((acc, exclude) => { 70 | return acc || minimatch(inputPath, exclude) 71 | }, false) 72 | } 73 | 74 | merge(rule) { 75 | const merged = new Rule( 76 | { 77 | task: rule.taskName || this.taskName, 78 | outputExt: rule.outputExt || this.outputExt, 79 | exclude: this.exclude.concat(rule.exclude), 80 | progeny: mergeOptions(this.progeny, rule.progeny) 81 | }, 82 | rule.inputExt 83 | ) 84 | 85 | // `task` may not loaded yet, so we need to check `taskName` instead. 86 | merged.task = rule.taskName ? rule.task : this.task 87 | 88 | return merged 89 | } 90 | 91 | static create(rawRule, inputExt, tasks, parent) { 92 | let rule = new Rule(rawRule, inputExt) 93 | if (parent) { 94 | rule = parent.merge(rule) 95 | } 96 | 97 | if (!rule.task) { 98 | assert(tasks[rule.taskName], `Task "${rule.taskName}" is not defined`) 99 | rule.task = createTask(tasks[rule.taskName], rawRule.options || {}) 100 | } 101 | 102 | return rule 103 | } 104 | 105 | static get empty() { 106 | return emptyRule 107 | } 108 | } 109 | module.exports = Rule 110 | 111 | /** 112 | * Assumes `a` and `b` is the same type 113 | * but if either one is `null` or `undefined`, it will be just ignored. 114 | */ 115 | function mergeOptions(a, b) { 116 | if (a == null) return b 117 | if (b == null) return a 118 | 119 | if (Array.isArray(a)) { 120 | return a.concat(b) 121 | } 122 | 123 | if (typeof a === 'object' && !(a instanceof RegExp)) { 124 | const res = {} 125 | mergedKeys(a, b).forEach(key => { 126 | res[key] = mergeOptions(a[key], b[key]) 127 | }) 128 | return res 129 | } 130 | 131 | return b 132 | } 133 | 134 | function mergedKeys(a, b) { 135 | const keys = {} 136 | Object.keys(a) 137 | .concat(Object.keys(b)) 138 | .forEach(key => { 139 | keys[key] = true 140 | }) 141 | return Object.keys(keys) 142 | } 143 | -------------------------------------------------------------------------------- /lib/models/task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function createTask(rawTask, options) { 4 | return stream => rawTask(stream, options) 5 | } 6 | -------------------------------------------------------------------------------- /lib/task-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Duplex = require('stream').Duplex 4 | const PassThrough = require('stream').PassThrough 5 | 6 | /** 7 | * Branch the input files to several streams 8 | * to process user defined tasks based on rules. 9 | * All processed files are pushed into TaskStream 10 | * so the user of this stream should not aware 11 | * there are multiple streams under the hood. 12 | */ 13 | class TaskStream extends Duplex { 14 | constructor(config) { 15 | super({ objectMode: true }) 16 | 17 | this._config = config 18 | 19 | // Create transform streams for each rule 20 | this._branches = this._createInternalStreams(config) 21 | 22 | // Teardown 23 | this.on('finish', () => this._destroy()) 24 | } 25 | 26 | _createInternalStreams(config) { 27 | const map = new Map() 28 | 29 | Object.keys(config.rules).forEach(key => { 30 | const rule = config.rules[key] 31 | 32 | const input = new PassThrough({ 33 | objectMode: true 34 | }) 35 | 36 | const output = rule 37 | .task(input) 38 | .on('data', file => { 39 | file.extname = '.' + rule.outputExt 40 | this.push(file) 41 | }) 42 | .on('error', err => this.emit('error', err)) 43 | 44 | // Register the source stream to use later. 45 | map.set(rule, { input, output }) 46 | }) 47 | 48 | return map 49 | } 50 | 51 | _destroy() { 52 | // To ensure to teardown the streams in correct order 53 | // we need to wait internal streams before finish `this`. 54 | // We have to notify the end of input to input stream of each branch 55 | // and listen the `end` event of each output stream 56 | // to avoid leakage of any data that need long time to transform. 57 | const branches = Array.from(this._branches.values()) 58 | branches.forEach(b => { 59 | b.input.push(null) 60 | }) 61 | 62 | waitAllStreams(branches.map(b => b.output), () => { 63 | this.push(null) 64 | }) 65 | } 66 | 67 | /** 68 | * If the file does not match any rules 69 | * it is immediately pushed to next stream. 70 | * Otherwise it is sent to cooresponding task stream. 71 | */ 72 | _write(file, encoding, done) { 73 | const rule = this._config.findRuleByInput(file.path) 74 | 75 | if (rule === null) { 76 | this.push(file) 77 | } else { 78 | const input = this._branches.get(rule).input 79 | input.push(file) 80 | } 81 | 82 | done() 83 | } 84 | 85 | // Do nothing since we just use this.push method in other places 86 | _read() {} 87 | 88 | // Update mtime and ctime for all pushed files 89 | push(file) { 90 | if (file) { 91 | updateTime(file) 92 | } 93 | super.push(file) 94 | } 95 | } 96 | 97 | function waitAllStreams(streams, done) { 98 | let rest = streams.length 99 | 100 | for (const s of streams) { 101 | s.on('end', () => { 102 | rest -= 1 103 | if (rest === 0) { 104 | done() 105 | } 106 | }) 107 | } 108 | } 109 | 110 | function updateTime(file) { 111 | if (!file.stat) return 112 | 113 | const now = Date.now() 114 | 115 | if (file.stat.mtime) { 116 | file.stat.mtime = new Date(now) 117 | } 118 | if (file.stat.ctime) { 119 | file.stat.ctime = new Date(now) 120 | } 121 | } 122 | 123 | module.exports = function taskStream(config) { 124 | return new TaskStream(config) 125 | } 126 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const os = require('os') 6 | 7 | exports.noop = () => {} 8 | 9 | function clone(obj) { 10 | const res = {} 11 | Object.keys(obj).forEach(key => { 12 | res[key] = obj[key] 13 | }) 14 | return res 15 | } 16 | exports.clone = clone 17 | 18 | exports.identity = val => val 19 | 20 | exports.merge = (a, b) => { 21 | const res = clone(a) 22 | Object.keys(b).forEach(key => { 23 | res[key] = b[key] 24 | }) 25 | return res 26 | } 27 | 28 | exports.mapValues = (val, fn) => { 29 | const res = {} 30 | Object.keys(val).forEach(key => { 31 | res[key] = fn(val[key], key) 32 | }) 33 | return res 34 | } 35 | 36 | exports.filterProps = (props, fn) => { 37 | const res = {} 38 | Object.keys(props).forEach(key => { 39 | if (fn(props[key], key)) { 40 | res[key] = props[key] 41 | } 42 | }) 43 | return res 44 | } 45 | 46 | exports.isLocalPath = pathname => { 47 | return /^[\.\/]/.test(pathname) 48 | } 49 | 50 | exports.normalizePath = pathname => { 51 | return pathname.split(path.sep).join('/') 52 | } 53 | 54 | exports.readFileSync = fileName => { 55 | try { 56 | return fs.readFileSync(fileName, 'utf8') 57 | } catch (err) { 58 | return undefined 59 | } 60 | } 61 | 62 | exports.writeFileSync = (fileName, data) => { 63 | fs.writeFileSync(fileName, data) 64 | } 65 | 66 | exports.dropWhile = (list, fn) => { 67 | let from = 0 68 | for (let i = 0; i < list.length; i++) { 69 | if (fn(list[i])) { 70 | from = i + 1 71 | } else { 72 | break 73 | } 74 | } 75 | return list.slice(from) 76 | } 77 | 78 | exports.getIp = () => { 79 | const networkInterfaces = os.networkInterfaces() 80 | const matches = [] 81 | 82 | Object.keys(networkInterfaces).forEach(function(item) { 83 | networkInterfaces[item].forEach(function(address) { 84 | if (address.internal === false && address.family === 'IPv4') { 85 | matches.push(address.address) 86 | } 87 | }) 88 | }) 89 | 90 | return matches 91 | } 92 | -------------------------------------------------------------------------------- /lib/watch.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const vfs = require('vinyl-fs') 4 | const hashSum = require('hash-sum') 5 | const loadConfig = require('./config').loadConfig 6 | const Cache = require('./cache') 7 | const DepResolver = require('./dep-resolver') 8 | const taskStream = require('./task-stream') 9 | const cacheStream = require('./cache-stream') 10 | const createWatcher = require('./externals/watcher') 11 | const WatchLogger = require('./loggers/watch-logger') 12 | const util = require('./util') 13 | 14 | /** 15 | * The top level function to start the watcher for incremental build. 16 | * The 1st parameter can accept the following options: 17 | * 18 | * config - Path to a houl config file 19 | * cache - Path to a houl cache file 20 | * dot - Include dot files in output 21 | * 22 | * The 2nd parameter is meant to be used for internal, 23 | * so the users should not use it. 24 | * 25 | * The return value is a chokidar watcher object 26 | * which the user can use to execute any further processing. 27 | */ 28 | function watch(options, debug) { 29 | debug = debug || { cb: util.noop } 30 | 31 | const config = loadConfig(options.config) 32 | 33 | const cache = new Cache(hashSum) 34 | const depResolver = DepResolver.create(config) 35 | 36 | // Restore cache data 37 | if (options.cache) { 38 | const cacheData = util.readFileSync(options.cache) 39 | 40 | if (cacheData) { 41 | const json = JSON.parse(cacheData) 42 | 43 | // Restore cache data 44 | cache.deserialize(json.cache) 45 | 46 | // Restore deps data 47 | depResolver.deserialize(json.deps) 48 | } 49 | } 50 | 51 | // Logger 52 | const logger = new WatchLogger(config, { 53 | console: debug.console 54 | }) 55 | 56 | stream(config.vinylInput).on('finish', debug.cb) 57 | return createWatcher(config, depResolver, (name, files, origin) => { 58 | if (name === 'add') { 59 | logger.addFile(origin) 60 | } else if (name === 'change') { 61 | logger.updateFile(origin) 62 | } 63 | 64 | const filtered = files.filter(f => !config.isExclude(f)) 65 | if (filtered.length === 0) return 66 | 67 | stream(filtered) 68 | .on('data', file => { 69 | logger.writeFile(file.path) 70 | }) 71 | .on('finish', debug.cb) 72 | }).on('ready', () => { 73 | logger.startWatching() 74 | }) 75 | 76 | function stream(sources) { 77 | return vfs 78 | .src(sources, { nodir: true, dot: options.dot, base: config.input }) 79 | .pipe(cacheStream(cache, depResolver, util.readFileSync)) 80 | .pipe(taskStream(config)) 81 | .pipe(vfs.dest(config.output)) 82 | .on('finish', () => { 83 | if (!options.cache) return 84 | 85 | const serialized = JSON.stringify({ 86 | cache: cache.serialize(), 87 | deps: depResolver.serialize() 88 | }) 89 | util.writeFileSync(options.cache, serialized) 90 | }) 91 | } 92 | } 93 | 94 | exports.watch = watch 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "houl", 3 | "version": "0.3.2", 4 | "description": "Full-contained static site workflow", 5 | "main": "lib/api.js", 6 | "bin": "bin/houl.js", 7 | "files": [ 8 | "bin", 9 | "lib" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/ktsn/houl.git" 14 | }, 15 | "keywords": [ 16 | "build tool", 17 | "static site", 18 | "gulp" 19 | ], 20 | "author": "katashin", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/ktsn/houl/issues" 24 | }, 25 | "homepage": "https://github.com/ktsn/houl#readme", 26 | "scripts": { 27 | "dev": "chokidar \"lib/**/*.js\" \"test/specs/**/*.js\" -c \"npm run test:unit\" --silent --initial", 28 | "lint": "eslint bin lib test/specs test/e2e", 29 | "lint:fix": "eslint --fix bin lib test/specs test/e2e", 30 | "test": "npm run lint && npm run test:unit && npm run test:e2e", 31 | "test:unit": "jasmine TEST=1 JASMINE_CONFIG_PATH=test/jasmine-unit.json", 32 | "test:e2e": "node test/e2e/setup.js && jasmine TEST=1 JASMINE_CONFIG_PATH=test/jasmine-e2e.json", 33 | "docs": "vuepress dev docs", 34 | "docs:build": "vuepress build docs", 35 | "release": "./release.sh" 36 | }, 37 | "devDependencies": { 38 | "chokidar-cli": "^1.2.2", 39 | "conventional-github-releaser": "^3.1.3", 40 | "eslint": "^5.16.0", 41 | "eslint-config-ktsn": "^2.0.1", 42 | "fs-extra": "^8.0.1", 43 | "jasmine": "^3.4.0", 44 | "normalize-path": "^3.0.0", 45 | "prettier": "^1.18.0", 46 | "prettier-config-ktsn": "^1.0.0", 47 | "testdouble": "^3.11.0", 48 | "vuepress": "^0.14.11" 49 | }, 50 | "dependencies": { 51 | "browser-sync": "^2.26.5", 52 | "chokidar": "^3.0.1", 53 | "hash-sum": "^1.0.2", 54 | "http-proxy-middleware": "^0.19.1", 55 | "mime": "^2.4.3", 56 | "minimatch": "^3.0.4", 57 | "progeny": "^0.12.0", 58 | "readable-stream": "^3.4.0", 59 | "vinyl-fs": "^3.0.3", 60 | "yargs": "^13.2.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('prettier-config-ktsn') -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | echo -n "Enter bump type: " 6 | read type 7 | 8 | npm test -s 9 | 10 | npm version $type 11 | npm publish 12 | git push origin master 13 | conventional-github-releaser -p angular 14 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | jasmine: true 4 | -------------------------------------------------------------------------------- /test/e2e/build.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const td = require('testdouble') 4 | const fse = require('fs-extra') 5 | const build = require('../../lib/cli/build').handler 6 | const e2eHelpers = require('../helpers/e2e') 7 | const updateSrc = e2eHelpers.updateSrc 8 | const removeDist = e2eHelpers.removeDist 9 | const compare = e2eHelpers.compare 10 | 11 | describe('Build CLI', () => { 12 | const config = 'test/fixtures/e2e/houl.config.json' 13 | const cache = 'test/fixtures/e2e/.cache.json' 14 | 15 | let revert, console 16 | 17 | beforeEach(() => { 18 | console = { 19 | log: td.function(), 20 | error: td.function() 21 | } 22 | 23 | removeDist() 24 | fse.removeSync(cache) 25 | 26 | process.env.NODE_ENV = null 27 | }) 28 | 29 | afterEach(() => { 30 | if (revert) { 31 | revert() 32 | revert = null 33 | } 34 | }) 35 | 36 | it('should build in develop mode', done => { 37 | build({ config }, { console }).then(() => { 38 | compare('dev') 39 | done() 40 | }) 41 | }) 42 | 43 | it('should build in production mode', done => { 44 | build( 45 | { 46 | config, 47 | production: true 48 | }, 49 | { console } 50 | ).then(() => { 51 | compare('prod') 52 | done() 53 | }) 54 | }) 55 | 56 | it('should not build cached files', done => { 57 | build({ config, cache }, { console }).then(() => { 58 | removeDist() 59 | revert = updateSrc() 60 | build({ config, cache }, { console }).then(() => { 61 | compare('cache') 62 | done() 63 | }) 64 | }) 65 | }) 66 | 67 | it('can output dot files', done => { 68 | build({ config, dot: true }, { console }).then(() => { 69 | compare('dot') 70 | done() 71 | }) 72 | }) 73 | 74 | it('can filter input files', done => { 75 | build({ config, filter: '**/*.scss' }, { console }).then(() => { 76 | compare('filtered') 77 | done() 78 | }) 79 | }) 80 | 81 | it('outputs log', done => { 82 | build({ config }, { console }).then(() => { 83 | td.verify(console.error(), { times: 0, ignoreExtraArgs: true }) 84 | td.verify(console.log('Building src -> dist'), { times: 1 }) 85 | td.verify(console.log(td.matchers.contains('Finished')), { times: 1 }) 86 | done() 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/e2e/dev.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const fse = require('fs-extra') 5 | const path = require('path') 6 | const td = require('testdouble') 7 | const dev = require('../../lib/cli/dev').handler 8 | const waitForData = require('../helpers').waitForData 9 | 10 | function get(pathName, port, cb) { 11 | http.get('http://localhost:' + port + pathName, waitForData(cb)) 12 | } 13 | 14 | function assertData(data, file, type) { 15 | type = type || 'dev' 16 | expect(data).toBe( 17 | fse.readFileSync(path.resolve(__dirname, '../expected', type, file), 'utf8') 18 | ) 19 | } 20 | 21 | describe('Dev CLI', () => { 22 | const config = 'test/fixtures/e2e/houl.config.json' 23 | 24 | let bs, watcher 25 | function run(options, cb) { 26 | const console = { 27 | log: td.function(), 28 | error: td.function() 29 | } 30 | 31 | const res = dev( 32 | { 33 | config: options.config, 34 | port: options.port || 3000, 35 | 'base-path': options['base-path'] || '/', 36 | _debug: true 37 | }, 38 | { 39 | console 40 | } 41 | ) 42 | 43 | bs = res.bs 44 | watcher = res.watcher 45 | 46 | bs.emitter.on('init', () => cb(bs)) 47 | 48 | return bs 49 | } 50 | 51 | let revert 52 | function update(file, cb) { 53 | const original = path.resolve(__dirname, '../fixtures/e2e/src', file) 54 | const updated = path.resolve(__dirname, '../fixtures/e2e/updated-src', file) 55 | 56 | function handleError(fn) { 57 | return (err, res) => { 58 | if (err) throw err 59 | fn(res) 60 | } 61 | } 62 | 63 | // prettier-ignore 64 | fse.readFile(original, 'utf8', handleError(temp => { 65 | // prettier-ignore 66 | fse.copy(updated, original, handleError(() => { 67 | revert = () => { 68 | fse.writeFileSync(original, temp) 69 | } 70 | cb() 71 | })) 72 | })) 73 | } 74 | 75 | afterEach(() => { 76 | if (bs) bs.exit() 77 | if (watcher) watcher.close() 78 | if (revert) revert() 79 | 80 | bs = null 81 | watcher = null 82 | revert = null 83 | }) 84 | 85 | it('starts a static file server', done => { 86 | run({ config }, () => { 87 | get('/index.html', 3000, (res, data) => { 88 | expect(res.statusCode).toBe(200) 89 | assertData(data, 'index.html') 90 | done() 91 | }) 92 | }) 93 | }) 94 | 95 | it('compiles files based on corresponding tasks', done => { 96 | run({ config }, () => { 97 | get('/css/index.css', 3000, (res, data) => { 98 | expect(res.statusCode).toBe(200) 99 | assertData(data, 'css/index.css') 100 | done() 101 | }) 102 | }) 103 | }) 104 | 105 | it('detect file changes considering its dependencies', done => { 106 | run({ config }, () => { 107 | get('/css/index.css', 3000, (res, data) => { 108 | assertData(data, 'css/index.css') 109 | 110 | update('css/_variables.scss', () => { 111 | get('/css/index.css', 3000, (res, data) => { 112 | assertData(data, 'css/index.css', 'cache') 113 | done() 114 | }) 115 | }) 116 | }) 117 | }) 118 | }) 119 | 120 | it('can be specified port number of dev server', done => { 121 | run({ config, port: 51234 }, () => { 122 | get('/js/index.js', 51234, (res, data) => { 123 | expect(res.statusCode).toBe(200) 124 | assertData(data, 'js/index.js') 125 | done() 126 | }) 127 | }) 128 | }) 129 | 130 | it('can be set base path of dev server', done => { 131 | const options = { config } 132 | options['base-path'] = 'path/to/base' 133 | run(options, () => { 134 | get('/path/to/base/js/index.js', 3000, (res, data) => { 135 | expect(res.statusCode).toBe(200) 136 | assertData(data, 'js/index.js') 137 | done() 138 | }) 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /test/e2e/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const spawn = require('child_process').spawn 5 | 6 | spawn('npm', ['install', '--no-save'], { 7 | shell: true, 8 | cwd: path.resolve(__dirname, '../fixtures/e2e') 9 | }) 10 | -------------------------------------------------------------------------------- /test/e2e/watch.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fse = require('fs-extra') 4 | const td = require('testdouble') 5 | const watch = require('../../lib/cli/watch').handler 6 | const e2eHelpers = require('../helpers/e2e') 7 | const addSrc = e2eHelpers.addSrc 8 | const updateSrc = e2eHelpers.updateSrc 9 | const removeDist = e2eHelpers.removeDist 10 | const compare = e2eHelpers.compare 11 | 12 | describe('Watch CLI', () => { 13 | const config = 'test/fixtures/e2e/houl.config.json' 14 | const cache = 'test/fixtures/e2e/.cache.json' 15 | 16 | let revert, watcher 17 | 18 | function run(options, cb) { 19 | if (watcher) { 20 | watcher.close() 21 | } 22 | const console = { 23 | log: td.function(), 24 | error: td.function() 25 | } 26 | watcher = watch(options, { cb, console }) 27 | } 28 | 29 | function add() { 30 | revert = addSrc() 31 | } 32 | 33 | function update() { 34 | revert = updateSrc() 35 | } 36 | 37 | beforeEach(() => { 38 | removeDist() 39 | fse.removeSync(cache) 40 | }) 41 | 42 | afterEach(() => { 43 | if (watcher) { 44 | watcher.close() 45 | watcher = null 46 | } 47 | 48 | if (revert) { 49 | revert() 50 | revert = null 51 | } 52 | }) 53 | 54 | it('should build all input files initially', done => { 55 | run( 56 | { config }, 57 | observe([ 58 | () => { 59 | compare('dev') 60 | update() 61 | }, 62 | () => { 63 | compare('updated') 64 | done() 65 | } 66 | ]) 67 | ) 68 | }) 69 | 70 | it('should build a newly added file', done => { 71 | run( 72 | { config }, 73 | observe([ 74 | () => { 75 | compare('dev') 76 | add() 77 | }, 78 | () => { 79 | compare('added') 80 | done() 81 | } 82 | ]) 83 | ) 84 | }) 85 | 86 | it('should build only updated files', done => { 87 | run( 88 | { config }, 89 | observe([ 90 | () => { 91 | removeDist() 92 | update() 93 | }, 94 | () => { 95 | compare('cache') 96 | done() 97 | } 98 | ]) 99 | ) 100 | }) 101 | 102 | it('should build only updated files even if after restart the command', done => { 103 | run( 104 | { config, cache }, 105 | observe([ 106 | () => { 107 | // Equivalent with exiting watch command 108 | watcher.close() 109 | 110 | removeDist() 111 | update() 112 | 113 | // Equivalent with restart watch command 114 | run( 115 | { config, cache }, 116 | observe([ 117 | () => { 118 | compare('cache') 119 | done() 120 | } 121 | ]) 122 | ) 123 | } 124 | ]) 125 | ) 126 | }) 127 | }) 128 | 129 | function observe(cbs) { 130 | const throttle = 10 131 | let timer = null 132 | let count = -1 133 | 134 | // Delay a watcher callback because a watcher may batch 135 | // multiple call for multiple file updates 136 | return () => { 137 | clearTimeout(timer) 138 | 139 | timer = setTimeout(() => { 140 | count += 1 141 | cbs[count]() 142 | }, throttle) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/expected/added/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/added/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 |

Additional file

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/expected/added/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/expected/added/js/index.js: -------------------------------------------------------------------------------- 1 | var message = 'Test message' 2 | console.log(message) 3 | 4 | /* In dev mode */ 5 | -------------------------------------------------------------------------------- /test/expected/added/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/expected/cache/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/cache/js/index.js: -------------------------------------------------------------------------------- 1 | var message = 'Updated message' 2 | console.log(message) 3 | 4 | /* In dev mode */ 5 | -------------------------------------------------------------------------------- /test/expected/dev/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/expected/dev/js/index.js: -------------------------------------------------------------------------------- 1 | var message = 'Test message' 2 | console.log(message) 3 | 4 | /* In dev mode */ 5 | -------------------------------------------------------------------------------- /test/expected/dev/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/expected/dot/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | -------------------------------------------------------------------------------- /test/expected/dot/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/dot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/expected/dot/js/index.js: -------------------------------------------------------------------------------- 1 | var message = 'Test message' 2 | console.log(message) 3 | 4 | /* In dev mode */ 5 | -------------------------------------------------------------------------------- /test/expected/dot/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/expected/filtered/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/prod/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/expected/prod/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/expected/prod/js/index.js: -------------------------------------------------------------------------------- 1 | var message="Test message";console.log(message); -------------------------------------------------------------------------------- /test/expected/prod/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/expected/updated/css/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | 5 | /* In dev mode */ 6 | -------------------------------------------------------------------------------- /test/expected/updated/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 |

Example title

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/expected/updated/js/index.js: -------------------------------------------------------------------------------- 1 | var message = 'Updated message' 2 | console.log(message) 3 | 4 | /* In dev mode */ 5 | -------------------------------------------------------------------------------- /test/expected/updated/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/configs/no-taskfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: './preset.config.js' 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/configs/normal-with-preset-modify.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | input: './src', 3 | output: './dist', 4 | preset: { 5 | name: './preset-function.config.js', 6 | modifyConfig: config => { 7 | delete config.rules.baz 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/configs/normal-with-preset-options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | input: './src', 3 | output: './dist', 4 | taskFile: './normal.task.js', 5 | preset: { 6 | name: './preset-function.config.js', 7 | options: { baz: 'bazOptions' } 8 | }, 9 | rules: { 10 | js: { 11 | task: 'task1', 12 | exclude: '**/vendor/**' 13 | }, 14 | scss: { 15 | outputExt: 'css', 16 | task: 'task2' 17 | }, 18 | png: 'imagemin' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/configs/normal.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | input: './src', 3 | output: './dist', 4 | taskFile: './normal.task.js', 5 | preset: './preset.config.js', 6 | rules: { 7 | js: { 8 | task: 'task1', 9 | exclude: '**/vendor/**' 10 | }, 11 | scss: { 12 | outputExt: 'css', 13 | task: 'task2' 14 | }, 15 | png: 'imagemin' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/configs/normal.task.js: -------------------------------------------------------------------------------- 1 | exports.task1 = () => { 2 | return 'foo' 3 | } 4 | 5 | exports.task2 = () => { 6 | return 'bar' 7 | } 8 | 9 | exports.imagemin = () => { 10 | return 'baz' 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/configs/preset-function.config.js: -------------------------------------------------------------------------------- 1 | module.exports = options => { 2 | return { 3 | taskFile: './preset.task.js', 4 | rules: { 5 | baz: { 6 | task: 'baz', 7 | options: options.baz 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/configs/preset.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | taskFile: './preset.task.js', 3 | rules: { 4 | js: { 5 | task: 'foo' 6 | }, 7 | scss: { 8 | outputExt: 'css', 9 | task: 'bar' 10 | }, 11 | gif: 'foo' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/configs/preset.task.js: -------------------------------------------------------------------------------- 1 | exports.foo = () => { 2 | return 'foo' 3 | } 4 | 5 | exports.bar = () => { 6 | return 'bar' 7 | } 8 | 9 | exports.baz = (_, options) => { 10 | return options 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/configs/test.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "src", 3 | "output": "dist", 4 | "taskFile": "normal.task.js" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.tmp/ 3 | /dist/ 4 | .cache.json 5 | -------------------------------------------------------------------------------- /test/fixtures/e2e/added-src/example.pug: -------------------------------------------------------------------------------- 1 | extends /default 2 | 3 | block contents 4 | h1 Example title 5 | p Additional file 6 | -------------------------------------------------------------------------------- /test/fixtures/e2e/houl.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "src", 3 | "output": "dist", 4 | "exclude": ["**/_*/**", "**/_*"], 5 | "taskFile": "./houl.task.js", 6 | "rules": { 7 | "js": { 8 | "task": "script", 9 | "exclude": "**/vendor/**" 10 | }, 11 | "scss": { 12 | "task": "style", 13 | "outputExt": "css" 14 | }, 15 | "pug": { 16 | "task": "pug", 17 | "outputExt": "html", 18 | "progeny": { 19 | "rootPath": "src/_layouts" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/e2e/houl.task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const houl = require('../../../') 5 | const dev = houl.dev 6 | const prod = houl.prod 7 | 8 | const buble = require('gulp-buble') 9 | const sass = require('gulp-sass') 10 | const pug = require('gulp-pug') 11 | const uglify = require('gulp-uglify') 12 | 13 | const devMark = () => require('stream').Transform({ 14 | objectMode: true, 15 | transform (file, encoding, done) { 16 | file.contents = Buffer.from(file.contents + '\n/* In dev mode */\n') 17 | done(null, file) 18 | } 19 | }) 20 | 21 | exports.script = stream => { 22 | return stream 23 | .pipe(buble()) 24 | .pipe(prod(uglify())) 25 | .pipe(dev(devMark())) 26 | } 27 | 28 | exports.style = stream => { 29 | return stream 30 | .pipe(sass({ 31 | outputStyle: 'expanded' 32 | })) 33 | .pipe(dev(devMark())) 34 | } 35 | 36 | exports.pug = stream => { 37 | return stream 38 | .pipe(pug({ 39 | basedir: path.resolve(__dirname, 'src/_layouts'), 40 | pretty: ' ' 41 | })) 42 | } 43 | -------------------------------------------------------------------------------- /test/fixtures/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "houl-fixtures", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "gulp-buble": "^0.8.0", 7 | "gulp-pug": "^3.3.0", 8 | "gulp-sass": "^3.1.0", 9 | "gulp-uglify": "^2.1.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/_layouts/default.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset="utf-8") 5 | title Example 6 | link(rel="stylesheet" href="css/index.css") 7 | body 8 | block contents 9 | script(src="js/vendor/lib.js") 10 | script(src="js/index.js") 11 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/css/_variables.scss: -------------------------------------------------------------------------------- 1 | $color-main: blue; 2 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/css/index.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | h1 { 4 | color: $color-main; 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/index.pug: -------------------------------------------------------------------------------- 1 | extends /default 2 | 3 | block contents 4 | h1 Example title 5 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/js/_excluded.js: -------------------------------------------------------------------------------- 1 | console.log('This should always be excluded') 2 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/js/index.js: -------------------------------------------------------------------------------- 1 | const message = 'Test message' 2 | console.log(message) 3 | -------------------------------------------------------------------------------- /test/fixtures/e2e/src/js/vendor/lib.js: -------------------------------------------------------------------------------- 1 | const foo = 'Some libs' 2 | window.Lib = { 3 | foo 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/e2e/updated-src/css/_variables.scss: -------------------------------------------------------------------------------- 1 | $color-main: red; 2 | -------------------------------------------------------------------------------- /test/fixtures/e2e/updated-src/js/_excluded.js: -------------------------------------------------------------------------------- 1 | console.log('This should be excluded still') 2 | -------------------------------------------------------------------------------- /test/fixtures/e2e/updated-src/js/index.js: -------------------------------------------------------------------------------- 1 | const message = 'Updated message' 2 | console.log(message) 3 | -------------------------------------------------------------------------------- /test/fixtures/sources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Houl Example 6 | 7 | 8 | 9 |

Houl

10 |

Gulp compatible build tool targeted for huge static sites

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/fixtures/sources/index.js: -------------------------------------------------------------------------------- 1 | const foo = 'foobar' 2 | console.log(foo) 3 | -------------------------------------------------------------------------------- /test/fixtures/sources/index.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/sources/sources/index.js: -------------------------------------------------------------------------------- 1 | const foo = 'for proxy test' 2 | console.log(foo) 3 | -------------------------------------------------------------------------------- /test/helpers/e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const fse = require('fs-extra') 5 | 6 | function createUpdateSrc (dirName) { 7 | return () => { 8 | const original = path.resolve(__dirname, '../fixtures/e2e/src') 9 | const temp = path.resolve(__dirname, '../fixtures/e2e/.tmp') 10 | const updated = path.resolve(__dirname, '../fixtures/e2e/' + dirName) 11 | 12 | fse.copySync(original, temp) 13 | fse.copySync(updated, original) 14 | 15 | return () => { 16 | fse.removeSync(original) 17 | fse.copySync(temp, original) 18 | fse.removeSync(temp) 19 | } 20 | } 21 | } 22 | 23 | exports.addSrc = createUpdateSrc('added-src') 24 | exports.updateSrc = createUpdateSrc('updated-src') 25 | 26 | exports.removeDist = function removeDist () { 27 | fse.removeSync(path.resolve(__dirname, '../fixtures/e2e/dist')) 28 | } 29 | 30 | exports.compare = function compare (type) { 31 | const actualDir = path.resolve(__dirname, '../fixtures/e2e/dist') 32 | const expectedDir = path.resolve(__dirname, '../expected', type) 33 | 34 | function loop (xs, ys) { 35 | if (xs.length === 0 && ys.length === 0) return 36 | if (xs.length > 0 && ys.length === 0 || xs.length === 0 && ys.length > 0) { 37 | console.log(xs, ys) // eslint-disable-line 38 | throw new Error('There are some inconsistencies between actual/expected files') 39 | } 40 | 41 | const xh = xs[0] 42 | const target = extract(xh, ys) 43 | 44 | if (target.index < 0) { 45 | throw new Error(`${xh} is not found in expected files`) 46 | } 47 | 48 | const statX = fse.statSync(actual(xh)) 49 | const statY = fse.statSync(expected(xh)) 50 | 51 | if (statX.isDirectory() && statY.isDirectory()) { 52 | loop( 53 | readdir(xh, actual), 54 | readdir(xh, expected) 55 | ) 56 | } else if (statX.isFile() && statY.isFile()) { 57 | compareItem(xh) 58 | } else { 59 | throw new Error(`${xh} is output in incorrect format`) 60 | } 61 | loop(xs.slice(1), target.extracted) 62 | } 63 | 64 | function readdir (dir, map) { 65 | return fse.readdirSync(map(dir)).map(file => path.join(dir, file)) 66 | } 67 | 68 | function actual (file) { 69 | return path.join(actualDir, file) 70 | } 71 | 72 | function expected (file) { 73 | return path.join(expectedDir, file) 74 | } 75 | 76 | function actualFile (file) { 77 | return fse.readFileSync(actual(file), 'utf8') 78 | } 79 | 80 | function expectedFile (file) { 81 | return fse.readFileSync(expected(file), 'utf8') 82 | } 83 | 84 | function compareItem (file) { 85 | expect(actualFile(file)).toBe(expectedFile(file)) 86 | } 87 | 88 | return loop([''], ['']) 89 | } 90 | 91 | function extract (x, ys) { 92 | function loop (x, pre, post) { 93 | if (post.length === 0) { 94 | return { 95 | index: -1, 96 | extracted: ys 97 | } 98 | } 99 | 100 | const head = post[0] 101 | const tail = post.slice(1) 102 | 103 | if (x === head) { 104 | return { 105 | index: pre.length, 106 | extracted: pre.concat(tail) 107 | } 108 | } else { 109 | return loop(x, pre.concat(head), tail) 110 | } 111 | } 112 | return loop(x, [], ys) 113 | } 114 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const stream = require('stream') 4 | const Readable = stream.Readable 5 | const Writable = stream.Writable 6 | const Transform = stream.Transform 7 | 8 | exports.vinyl = function vinyl (options) { 9 | if (typeof options.contents === 'string') { 10 | options.contents = Buffer.from(options.contents) 11 | } 12 | 13 | Object.defineProperty(options, 'extname', { 14 | get () { 15 | return '.' + this.path.split('.').pop() 16 | }, 17 | set (value) { 18 | const filePath = this.path.split('.') 19 | filePath.pop() 20 | this.path = filePath.join('.') + value 21 | } 22 | }) 23 | 24 | return options 25 | } 26 | 27 | exports.assertStream = function assertStream (expected) { 28 | let count = 0 29 | 30 | return new Writable({ 31 | objectMode: true, 32 | write (data, encoding, cb) { 33 | expect(data).toEqual(expected[count]) 34 | 35 | count += 1 36 | cb(null, data) 37 | } 38 | }).on('finish', () => { 39 | expect(count).toBe(expected.length) 40 | }) 41 | } 42 | 43 | exports.source = function source (input) { 44 | return new Readable({ 45 | objectMode: true, 46 | read () { 47 | input.forEach(data => this.push(data)) 48 | this.push(null) 49 | } 50 | }) 51 | } 52 | 53 | exports.transform = function transform (fn) { 54 | return new Transform({ 55 | objectMode: true, 56 | transform: fn 57 | }) 58 | } 59 | 60 | exports.waitForData = function waitForData (fn) { 61 | let buf = '' 62 | 63 | return res => { 64 | res.on('data', chunk => buf += chunk) 65 | res.on('end', () => { 66 | fn(res, buf) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/jasmine-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "test/e2e", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "../../node_modules/babel-register/lib/node.js", 8 | "../jasmine-helpers.js" 9 | ], 10 | "stopSpecOnExpectationFailure": false, 11 | "random": false 12 | } 13 | -------------------------------------------------------------------------------- /test/jasmine-helpers.js: -------------------------------------------------------------------------------- 1 | const _normalize = require('normalize-path') 2 | function normalize (value) { 3 | return _normalize(value.replace(/^\w:\\/, '\\')) 4 | } 5 | 6 | beforeEach(() => { 7 | jasmine.addMatchers({ 8 | toBePath (util, customEqualityTesters) { 9 | return { 10 | compare (actual, expected) { 11 | actual = normalize(actual) 12 | expected = normalize(expected) 13 | 14 | const result = {} 15 | result.pass = util.equals(actual, expected, customEqualityTesters) 16 | 17 | if (!result.pass) { 18 | result.message = 'Expected ' + expected + ' but found ' + actual 19 | } 20 | 21 | return result 22 | } 23 | } 24 | } 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/jasmine-unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "test/specs", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "../../node_modules/babel-register/lib/node.js", 8 | "../jasmine-helpers.js" 9 | ], 10 | "stopSpecOnExpectationFailure": false, 11 | "random": false 12 | } 13 | -------------------------------------------------------------------------------- /test/specs/api.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const execSync = require('child_process').execSync 5 | 6 | const p = _path => path.resolve(__dirname, _path) 7 | const exec = (file, nodeEnv) => { 8 | const env = Object.create(process.env) 9 | env.NODE_ENV = nodeEnv 10 | 11 | return execSync('node ' + p(file), { env }) 12 | .toString() 13 | .trim() 14 | } 15 | 16 | describe('Node API', () => { 17 | it('dev helper', () => { 18 | let stdout = exec('./env/dev-task.js', 'developement') 19 | expect(stdout).toBe('transformed') 20 | 21 | stdout = exec('./env/dev-task.js', 'production') 22 | expect(stdout).toBe('source') 23 | }) 24 | 25 | it('prod helper', () => { 26 | let stdout = exec('./env/prod-task.js', 'developement') 27 | expect(stdout).toBe('source') 28 | 29 | stdout = exec('./env/prod-task.js', 'production') 30 | expect(stdout).toBe('transformed') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/specs/cache-stream.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Cache = require('../../lib/cache') 4 | const DepResolver = require('../../lib/dep-resolver') 5 | const cacheStream = require('../../lib/cache-stream') 6 | 7 | const helpers = require('../helpers') 8 | const assertStream = helpers.assertStream 9 | const source = helpers.source 10 | 11 | const emptyArray = () => [] 12 | const emptyStr = () => '' 13 | 14 | describe('Cache Stream', () => { 15 | it('should not update a cache until the stream is finished', done => { 16 | const cache = new Cache() 17 | const depResolver = new DepResolver(emptyArray) 18 | 19 | source([ 20 | { path: 'foo.txt', contents: 'abc' }, 21 | { path: 'bar.txt', contents: 'def' }, 22 | { path: 'foo.txt', contents: 'abc' } 23 | ]) 24 | .pipe(cacheStream(cache, depResolver, emptyStr)) 25 | .pipe( 26 | assertStream([ 27 | { path: 'foo.txt', contents: 'abc' }, 28 | { path: 'bar.txt', contents: 'def' }, 29 | { path: 'foo.txt', contents: 'abc' } 30 | ]) 31 | ) 32 | .on('finish', done) 33 | }) 34 | 35 | it('should not affect latter items even if previous item updates nested deps', done => { 36 | // last: a.txt -> b.txt -> c.txt 37 | // current: a.txt -> b.txt -> d.txt 38 | // With this structure, if a.txt and b.txt passes to 39 | // cache stream in this order, both files should not be hit the cache. 40 | 41 | const cache = new Cache() 42 | const depResolver = new DepResolver((_, content) => { 43 | return content ? [content] : [] 44 | }) 45 | 46 | cache.deserialize({ 47 | 'a.txt': 'b.txt', 48 | 'b.txt': 'c.txt', 49 | 'c.txt': '', 50 | 'd.txt': '' 51 | }) 52 | 53 | depResolver.deserialize({ 54 | 'a.txt': ['b.txt'], 55 | 'b.txt': ['c.txt'] 56 | }) 57 | 58 | const mockFs = pathName => { 59 | return { 60 | 'a.txt': 'b.txt', 61 | 'b.txt': 'd.txt', 62 | 'c.txt': '', 63 | 'd.txt': '' 64 | }[pathName] 65 | } 66 | 67 | source([ 68 | { path: 'a.txt', contents: 'b.txt' }, 69 | { path: 'b.txt', contents: 'd.txt' } 70 | ]) 71 | .pipe(cacheStream(cache, depResolver, mockFs)) 72 | .pipe( 73 | assertStream([ 74 | { path: 'a.txt', contents: 'b.txt' }, 75 | { path: 'b.txt', contents: 'd.txt' } 76 | ]) 77 | ) 78 | .on('finish', done) 79 | }) 80 | 81 | it('passes data if original source does not hit with cache', done => { 82 | const cache = new Cache() 83 | const depResolver = new DepResolver(() => ['bar.txt']) 84 | 85 | cache.deserialize({ 86 | 'foo.txt': 'abc', 87 | 'bar.txt': 'def' 88 | }) 89 | 90 | depResolver.deserialize({ 91 | 'foo.txt': ['bar.txt'] 92 | }) 93 | 94 | const mockFs = pathName => { 95 | return { 96 | 'foo.txt': 'updated', 97 | 'bar.txt': 'def' 98 | }[pathName] 99 | } 100 | 101 | // Shold foo.txt be updated? 102 | // contents -> updated 103 | // deps -> not updated 104 | // deps contents -> not updated 105 | // -> should be updated 106 | source([{ path: 'foo.txt', contents: 'updated' }]) 107 | .pipe(cacheStream(cache, depResolver, mockFs)) 108 | .pipe(assertStream([{ path: 'foo.txt', contents: 'updated' }])) 109 | .on('finish', done) 110 | }) 111 | 112 | it('passes data if deps contents are updated', done => { 113 | const cache = new Cache() 114 | const depResolver = new DepResolver(() => ['bar.txt']) 115 | 116 | cache.deserialize({ 117 | 'foo.txt': 'abc', 118 | 'bar.txt': 'def' 119 | }) 120 | 121 | depResolver.deserialize({ 122 | 'foo.txt': ['bar.txt'] 123 | }) 124 | 125 | const mockFs = pathName => { 126 | return { 127 | 'foo.txt': 'abc', 128 | 'bar.txt': 'updated' 129 | }[pathName] 130 | } 131 | 132 | // Shold foo.txt be updated? 133 | // contents -> not updated 134 | // deps -> not updated 135 | // deps contents -> updated (bar.txt) 136 | // -> should be updated 137 | source([{ path: 'foo.txt', contents: 'abc' }]) 138 | .pipe(cacheStream(cache, depResolver, mockFs)) 139 | .pipe(assertStream([{ path: 'foo.txt', contents: 'abc' }])) 140 | .on('finish', done) 141 | }) 142 | 143 | it('filters data if there are no update in any processes', done => { 144 | const cache = new Cache() 145 | const depResolver = new DepResolver(() => ['bar.txt']) 146 | 147 | cache.deserialize({ 148 | 'foo.txt': 'abc', 149 | 'bar.txt': 'edf' 150 | }) 151 | 152 | depResolver.deserialize({ 153 | 'foo.txt': ['bar.txt'] 154 | }) 155 | 156 | const mockFs = pathName => { 157 | return { 158 | 'foo.txt': 'abc', 159 | 'bar.txt': 'edf' 160 | }[pathName] 161 | } 162 | 163 | // Shold foo.txt be updated? 164 | // contents -> not updated 165 | // deps -> not updated 166 | // deps contents -> not updated 167 | // -> should not be updated 168 | source([{ path: 'foo.txt', contents: 'abc' }]) 169 | .pipe(cacheStream(cache, depResolver, mockFs)) 170 | .pipe(assertStream([])) 171 | .on('finish', done) 172 | }) 173 | 174 | it('updates the caches of all nested dependencies', done => { 175 | const cache = new Cache() 176 | const depResolver = new DepResolver(() => ['bar.txt', 'baz.txt']) 177 | 178 | cache.deserialize({ 179 | 'foo.txt': 'abc', 180 | 'bar.txt': 'edf', 181 | 'baz.txt': 'ghi' 182 | }) 183 | 184 | depResolver.deserialize({ 185 | 'foo.txt': ['bar.txt', 'baz.txt'] 186 | }) 187 | 188 | const mockFs = pathName => { 189 | return { 190 | 'foo.txt': 'abc', 191 | 'bar.txt': 'updated', 192 | 'baz.txt': 'updated' 193 | }[pathName] 194 | } 195 | 196 | source([{ path: 'foo.txt', contents: 'abc' }]) 197 | .pipe(cacheStream(cache, depResolver, mockFs)) 198 | .on('finish', () => { 199 | expect(cache.serialize()).toEqual({ 200 | 'foo.txt': 'abc', 201 | 'bar.txt': 'updated', 202 | 'baz.txt': 'updated' 203 | }) 204 | done() 205 | }) 206 | }) 207 | 208 | // #16 209 | it('should update all cache and deps for nested dependencies even if root cache does not hit', done => { 210 | const cache = new Cache() 211 | const depResolver = new DepResolver(() => ['bar.txt', 'qux.txt']) 212 | 213 | cache.deserialize({ 214 | 'foo.txt': '123', 215 | 'bar.txt': '456', 216 | 'baz.txt': '789', 217 | 'qux.txt': 'abc' 218 | }) 219 | 220 | depResolver.deserialize({ 221 | 'foo.txt': ['bar.txt', 'baz.txt'] 222 | }) 223 | 224 | const mockFs = pathName => { 225 | return { 226 | 'foo.txt': 'updated', 227 | 'bar.txt': 'updated', 228 | 'baz.txt': 'updated', 229 | 'qux.txt': 'updated' 230 | }[pathName] 231 | } 232 | 233 | source([{ path: 'foo.txt', contents: 'updated' }]) 234 | .pipe(cacheStream(cache, depResolver, mockFs)) 235 | .on('finish', () => { 236 | expect(cache.serialize()).toEqual({ 237 | 'foo.txt': 'updated', 238 | 'bar.txt': 'updated', 239 | 'baz.txt': '789', // Cannot update out of deps 240 | 'qux.txt': 'updated' 241 | }) 242 | 243 | expect(depResolver.serialize()).toEqual( 244 | jasmine.objectContaining({ 245 | 'foo.txt': ['bar.txt', 'qux.txt'] 246 | }) 247 | ) 248 | 249 | done() 250 | }) 251 | }) 252 | 253 | // #26 254 | it('should not pass undefined value to file request callback of dep resolver', done => { 255 | // Naive dep resolver from JavaScript content 256 | const extractDeps = content => { 257 | const re = /import "(.+?)"/g 258 | const res = [] 259 | let m 260 | while (m = re.exec(content)) { // eslint-disable-line 261 | res.push(m[1]) 262 | } 263 | return res 264 | } 265 | 266 | // Fake JavaScript files 267 | const stubFs = { 268 | 'root.js': 'import "second.js"', 269 | 'second.js': 'import "foo.js"\nimport "bar.js"', 270 | 'foo.js': 'alert("foo")', 271 | 'bar.js': 'alert("bar")' 272 | } 273 | 274 | const cache = new Cache() 275 | const depResolver = new DepResolver((_, content) => { 276 | // Progeny will throw an error if `content` is undefined 277 | expect(content).not.toBeUndefined() 278 | return extractDeps(content) 279 | }) 280 | 281 | const test = () => { 282 | return source([{ path: 'root.js', contents: stubFs['root.js'] }]).pipe( 283 | cacheStream(cache, depResolver, fileName => stubFs[fileName]) 284 | ) 285 | } 286 | 287 | // Save base cache at first 288 | test().on('finish', () => { 289 | // Update the source files 290 | delete stubFs['bar.js'] 291 | stubFs['second.js'] = 'import "foo.js"' 292 | 293 | test().on('finish', done) 294 | }) 295 | }) 296 | }) 297 | -------------------------------------------------------------------------------- /test/specs/cache.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Cache = require('../../lib/cache') 4 | 5 | describe('Cache', () => { 6 | it('tests cache hit with file name and contents', () => { 7 | const cache = new Cache() 8 | 9 | cache.register('test.txt', 'abc') 10 | 11 | expect(cache.test('test.txt', 'abc')).toBe(true) 12 | expect(cache.test('foo.txt', 'abc')).toBe(false) 13 | expect(cache.test('test.txt', 'def')).toBe(false) 14 | }) 15 | 16 | it('clears cache by file name', () => { 17 | const cache = new Cache() 18 | 19 | cache.register('test.txt', 'abc') 20 | expect(cache.test('test.txt', 'abc')).toBe(true) 21 | 22 | cache.clear('test.txt') 23 | expect(cache.test('test.txt', 'abc')).toBe(false) 24 | }) 25 | 26 | it('serializes cache map', () => { 27 | const cache = new Cache() 28 | 29 | cache.register('foo.txt', 'abc') 30 | cache.register('bar.txt', 'def') 31 | 32 | expect(cache.serialize()).toEqual({ 33 | 'foo.txt': 'abc', 34 | 'bar.txt': 'def' 35 | }) 36 | }) 37 | 38 | it('deserializes cache map', () => { 39 | const cache = new Cache() 40 | 41 | cache.deserialize({ 42 | 'foo.txt': 'abc', 43 | 'bar.txt': 'def' 44 | }) 45 | 46 | expect(cache.test('foo.txt', 'abc')).toBe(true) 47 | expect(cache.test('bar.txt', 'def')).toBe(true) 48 | }) 49 | 50 | it('hashes cache data by provided transformer', () => { 51 | const cache = new Cache(content => 'abc' + content) 52 | 53 | cache.register('foo.txt', 'def') 54 | 55 | expect(cache.test('foo.txt', 'def')).toBe(true) 56 | expect(cache.serialize()).toEqual({ 57 | 'foo.txt': 'abcdef' 58 | }) 59 | }) 60 | 61 | it('stores additional data when register cache', () => { 62 | const cache = new Cache() 63 | 64 | cache.register('foo.txt', 'abc', 'def') 65 | expect(cache.get('foo.txt')).toBe('def') 66 | }) 67 | 68 | it('clears cached data', () => { 69 | const cache = new Cache() 70 | 71 | cache.register('foo.txt', 'abc', 'def') 72 | cache.clear('foo.txt') 73 | expect(cache.get('foo.txt')).toBe(undefined) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/specs/config.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const normalize = require('normalize-path') 5 | const loadConfig = require('../../lib/config').loadConfig 6 | const findConfig = require('../../lib/config').findConfig 7 | 8 | const read = pathname => { 9 | return loadConfig(path.join('test/fixtures/configs', pathname)) 10 | } 11 | 12 | describe('Config', () => { 13 | it('loads js file', () => { 14 | read('normal.config.js') 15 | }) 16 | 17 | it('loads json file', () => { 18 | read('test.config.json') 19 | }) 20 | 21 | it('throws if try loading other file', () => { 22 | expect(() => read('test.coffee')).toThrowError( 23 | /test\.coffee is non-supported file format/ 24 | ) 25 | }) 26 | 27 | it('throws if no config file is found', () => { 28 | expect(() => read('not-exist.json')).toThrowError( 29 | /not-exist\.json is not found/ 30 | ) 31 | }) 32 | 33 | it('loads a function style config', () => { 34 | const config = read('preset-function.config.js') 35 | expect(config.rules.baz).not.toBe(undefined) 36 | }) 37 | 38 | it('loads a preset', () => { 39 | const config = read('normal.config.js') 40 | expect(config.rules.gif).not.toBe(undefined) 41 | }) 42 | 43 | it('loads a preset with the object format property', () => { 44 | const config = read('normal-with-preset-options.js') 45 | expect(config.rules.baz.task()).toBe('bazOptions') 46 | }) 47 | 48 | it('modifies a preset object', () => { 49 | const config = read('normal-with-preset-modify.js') 50 | expect(config.rules.baz).toBe(undefined) 51 | }) 52 | 53 | it('allows an empty taskFile field', () => { 54 | expect(() => { 55 | read('no-taskfile.js') 56 | }).not.toThrow() 57 | }) 58 | 59 | it('search config file', () => { 60 | function exists(pathname) { 61 | return '/path/houl.config.js' === normalize(pathname) 62 | } 63 | 64 | expect(findConfig('/path/to/project', exists)).toBePath( 65 | '/path/houl.config.js' 66 | ) 67 | }) 68 | 69 | it('also search json config file', () => { 70 | function exists(pathname) { 71 | return '/path/houl.config.json' === normalize(pathname) 72 | } 73 | 74 | expect(findConfig('/path/to/project', exists)).toBePath( 75 | '/path/houl.config.json' 76 | ) 77 | }) 78 | 79 | it('prefers js config', () => { 80 | function exists(pathname) { 81 | return ( 82 | ['/path/to/houl.config.json', '/path/to/houl.config.js'].indexOf( 83 | normalize(pathname) 84 | ) >= 0 85 | ) 86 | } 87 | 88 | expect(findConfig('/path/to/', exists)).toBePath('/path/to/houl.config.js') 89 | }) 90 | 91 | it('returns null if not found', () => { 92 | function exists(pathname) { 93 | return '/path/to/houl.config.json' === normalize(pathname) 94 | } 95 | 96 | expect(findConfig('/path/other/project', exists)).toBe(null) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /test/specs/dep-cache.spec.js: -------------------------------------------------------------------------------- 1 | const Cache = require('../../lib/cache') 2 | const DepResolver = require('../../lib/dep-resolver') 3 | const DepCache = require('../../lib/dep-cache') 4 | 5 | describe('DepCache', () => { 6 | it('returns true if it hits the cache including all deps', () => { 7 | // foo.txt -> bar.txt -> baz.txt 8 | const readFile = file => { 9 | return { 10 | 'bar.txt': 'bar', 11 | 'baz.txt': 'baz' 12 | }[file] 13 | } 14 | 15 | const resolveDep = file => { 16 | switch (file) { 17 | case 'foo.txt': 18 | return ['bar.txt'] 19 | case 'bar.txt': 20 | return ['baz.txt'] 21 | default: 22 | return [] 23 | } 24 | } 25 | 26 | const cache = new Cache() 27 | const depResolver = new DepResolver(resolveDep) 28 | const depCache = new DepCache(cache, depResolver, readFile) 29 | 30 | expect(depCache.test('foo.txt', 'foo')).toBe(false) 31 | depCache.register('foo.txt', 'foo') 32 | expect(depCache.test('foo.txt', 'foo')).toBe(true) 33 | }) 34 | 35 | it('returns false if one of deps is changed', () => { 36 | let bazContent = 'baz' 37 | 38 | // foo.txt -> bar.txt -> baz.txt 39 | const readFile = file => { 40 | return { 41 | 'bar.txt': 'bar', 42 | 'baz.txt': bazContent 43 | }[file] 44 | } 45 | 46 | const resolveDep = file => { 47 | switch (file) { 48 | case 'foo.txt': 49 | return ['bar.txt'] 50 | case 'bar.txt': 51 | return ['baz.txt'] 52 | default: 53 | return [] 54 | } 55 | } 56 | 57 | const cache = new Cache() 58 | const depResolver = new DepResolver(resolveDep) 59 | const depCache = new DepCache(cache, depResolver, readFile) 60 | 61 | depCache.register('foo.txt', 'foo') 62 | bazContent = 'baz updated' 63 | expect(depCache.test('foo.txt', 'foo')).toBe(false) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/specs/dep-resolver.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DepResolver = require('../../lib/dep-resolver') 4 | 5 | describe('DepResolver', () => { 6 | it('registers dependent files', () => { 7 | const r = new DepResolver(() => ['/path/to/dep.js']) 8 | 9 | // origin.js -depends-> dep.js 10 | r.register('/path/to/origin.js', '') 11 | expect(r.getInDeps('/path/to/dep.js')).toEqual(['/path/to/origin.js']) 12 | 13 | // origin.js -depends--> dep.js 14 | // another.js -depends-^ 15 | r.register('/path/to/another.js', '') 16 | expect(r.getInDeps('/path/to/dep.js')).toEqual([ 17 | '/path/to/origin.js', 18 | '/path/to/another.js' 19 | ]) 20 | }) 21 | 22 | it('should not have redundant file paths', () => { 23 | const r = new DepResolver(() => ['/path/to/dep.js']) 24 | 25 | r.register('/path/to/origin.js', '') 26 | r.register('/path/to/origin.js', '') 27 | 28 | expect(r.getInDeps('/path/to/dep.js')).toEqual(['/path/to/origin.js']) 29 | }) 30 | 31 | it('clears provided file', () => { 32 | const r = new DepResolver((_, content) => content.split(',')) 33 | 34 | // a --> n --> x 35 | // ^ | | ^ 36 | // b -^ -> y 37 | r.register('/a.js', '/n.js') 38 | r.register('/b.js', '/n.js,/a.js') 39 | r.register('/n.js', '/x.js,/y.js') 40 | r.register('/y.js', '/x.js') 41 | 42 | expect(r.serialize()).toEqual({ 43 | '/a.js': ['/n.js'], 44 | '/b.js': ['/n.js', '/a.js'], 45 | '/n.js': ['/x.js', '/y.js'], 46 | '/y.js': ['/x.js'], 47 | '/x.js': [] 48 | }) 49 | 50 | r.clear('/n.js') 51 | 52 | // Should not remove inDeps of `n` because 53 | // the deps may still refer `n` even if it has gone. 54 | // 55 | // a --> (n) 56 | // ^ | 57 | // b -^ 58 | // 59 | // y -> x 60 | expect(r.serialize()).toEqual({ 61 | '/a.js': ['/n.js'], 62 | '/b.js': ['/n.js', '/a.js'], 63 | '/y.js': ['/x.js'], 64 | '/x.js': [] 65 | }) 66 | }) 67 | 68 | it('resolves nested dependencies', () => { 69 | const r = new DepResolver((_, content) => [content]) 70 | 71 | // a --> b -> d 72 | // c -^ 73 | r.register('/a.js', '/b.js') 74 | r.register('/c.js', '/b.js') 75 | r.register('/b.js', '/d.js') 76 | 77 | expect(r.getInDeps('/d.js')).toEqual(['/b.js', '/a.js', '/c.js']) 78 | }) 79 | 80 | it('handles circlar dependencies', () => { 81 | const r = new DepResolver((_, content) => [content]) 82 | 83 | // a -> b -> c -> a -> ... 84 | r.register('/a.js', '/b.js') 85 | r.register('/b.js', '/c.js') 86 | r.register('/c.js', '/a.js') 87 | 88 | expect(r.getInDeps('/a.js')).toEqual(['/c.js', '/b.js']) 89 | }) 90 | 91 | it('overwrites the dependencies of the file having the same name', () => { 92 | const r = new DepResolver((_, content) => [content]) 93 | 94 | // foo --> test 95 | // bar -^ 96 | r.register('/foo.js', '/test.js') 97 | r.register('/bar.js', '/test.js') 98 | expect(r.getInDeps('/test.js')).toEqual(['/foo.js', '/bar.js']) 99 | 100 | r.register('/foo.js', '/test2.js') 101 | expect(r.getInDeps('/test.js')).toEqual(['/bar.js']) 102 | expect(r.getInDeps('/test2.js')).toEqual(['/foo.js']) 103 | }) 104 | 105 | it('returns empty array if target is not registered', () => { 106 | const r = new DepResolver(() => ['noop']) 107 | expect(r.getInDeps('/test.js')).toEqual([]) 108 | }) 109 | 110 | it('provides nested out deps', () => { 111 | const r = new DepResolver((_, content) => [content]) 112 | 113 | // a --> b -> d 114 | // c -^ 115 | r.register('/a.js', '/b.js') 116 | r.register('/c.js', '/b.js') 117 | r.register('/b.js', '/d.js') 118 | 119 | expect(r.getOutDeps('/a.js')).toEqual(['/b.js', '/d.js']) 120 | }) 121 | 122 | it('serializes deps', () => { 123 | const r = new DepResolver(() => ['/baz.js']) 124 | 125 | // foo --> baz 126 | // bar -^ 127 | r.register('/foo.js', '') 128 | r.register('/bar.js', '') 129 | 130 | expect(r.serialize()).toEqual({ 131 | '/foo.js': ['/baz.js'], 132 | '/bar.js': ['/baz.js'], 133 | '/baz.js': [] 134 | }) 135 | }) 136 | 137 | it('deserializes deps', () => { 138 | const r = new DepResolver(() => []) 139 | 140 | r.deserialize({ 141 | '/foo.js': ['/baz.js'], 142 | '/bar.js': ['/baz.js'] 143 | }) 144 | 145 | expect(r.getInDeps('/baz.js')).toEqual(['/foo.js', '/bar.js']) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /test/specs/env/dev-task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const dev = require('../../../lib/api').dev 4 | const helpers = require('../../helpers') 5 | const source = helpers.source 6 | const transform = helpers.transform 7 | 8 | const trans = transform((text, encoding, done) => { 9 | done(null, 'transformed') 10 | }) 11 | 12 | source(['source']) 13 | .pipe(dev(trans)) 14 | .on('data', data => console.log(data)) // eslint-disable-line 15 | -------------------------------------------------------------------------------- /test/specs/env/prod-task.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const prod = require('../../../lib/api').prod 4 | const helpers = require('../../helpers') 5 | const source = helpers.source 6 | const transform = helpers.transform 7 | 8 | const trans = transform((text, encoding, done) => { 9 | done(null, 'transformed') 10 | }) 11 | 12 | source(['source']) 13 | .pipe(prod(trans)) 14 | .on('data', data => console.log(data)) // eslint-disable-line 15 | -------------------------------------------------------------------------------- /test/specs/externals/browser-sync.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const http = require('http') 6 | const td = require('testdouble') 7 | const { transform, waitForData } = require('../../helpers') 8 | const Config = require('../../../lib/models/config') 9 | const Cache = require('../../../lib/cache') 10 | const DepResolver = require('../../../lib/dep-resolver') 11 | const DepCache = require('../../../lib/dep-cache') 12 | const create = require('../../../lib/externals/browser-sync') 13 | 14 | const base = path.resolve(__dirname, '../../fixtures') 15 | 16 | function reqTo(pathname) { 17 | return 'http://localhost:51234' + pathname 18 | } 19 | 20 | function expectDataToBeFile(data, filename) { 21 | expect(data).toBe(fs.readFileSync(path.join(base, filename), 'utf8')) 22 | } 23 | 24 | function updateFile(filename, data) { 25 | const filePath = path.join(base, filename) 26 | const original = fs.readFileSync(filePath) 27 | fs.writeFileSync(filePath, data) 28 | 29 | return () => { 30 | fs.writeFileSync(filePath, original) 31 | } 32 | } 33 | 34 | function createWaitCallback(n, done) { 35 | let count = 0 36 | return () => { 37 | count++ 38 | if (count === n) { 39 | done() 40 | } 41 | } 42 | } 43 | 44 | describe('Using browsersync', () => { 45 | const config = Config.create( 46 | { 47 | input: '', 48 | output: 'dist', 49 | rules: { 50 | js: 'js' 51 | }, 52 | dev: { 53 | port: 51234 54 | } 55 | }, 56 | { 57 | js: stream => { 58 | return stream.pipe( 59 | transform((file, encoding, callback) => { 60 | const source = file.contents.toString() 61 | file.contents = Buffer.from('**transformed**\n' + source) 62 | callback(null, file) 63 | }) 64 | ) 65 | } 66 | }, 67 | { base } 68 | ) 69 | 70 | let cache, depResolver 71 | const createDepCache = () => { 72 | cache = new Cache() 73 | depResolver = DepResolver.create(config) 74 | depResolver.register = td.function(depResolver.register.bind(depResolver)) 75 | return new DepCache(cache, depResolver, () => '') 76 | } 77 | 78 | let bs 79 | describe('without base path', () => { 80 | beforeAll(done => { 81 | bs = create( 82 | config, 83 | { 84 | open: false, 85 | logLevel: 'silent' 86 | }, 87 | createDepCache() 88 | ) 89 | 90 | bs.emitter.on('init', done) 91 | }) 92 | 93 | afterAll(() => { 94 | bs.exit() 95 | }) 96 | 97 | it('starts dev server by the given port', done => { 98 | http.get( 99 | reqTo('/sources/'), 100 | waitForData((res, data) => { 101 | expect(res.statusCode).toBe(200) 102 | expectDataToBeFile(data, 'sources/index.html') 103 | done() 104 | }) 105 | ) 106 | }) 107 | 108 | it('executes corresponding task to transform sources', done => { 109 | http.get( 110 | reqTo('/sources/index.js'), 111 | waitForData((res, data) => { 112 | expect(data).toMatch(/^\*\*transformed\*\*\n/) 113 | done() 114 | }) 115 | ) 116 | }) 117 | 118 | it('redirects if the specified path does not have trailing slash and it points to a directory', done => { 119 | http.get(reqTo('/sources'), res => { 120 | expect(res.statusCode).toBe(301) 121 | expect(res.headers.location).toBe('/sources/') 122 | done() 123 | }) 124 | }) 125 | 126 | it('registers requested files to dep resolver', done => { 127 | http.get(reqTo('/sources/index.scss'), () => { 128 | const absPath = path.resolve(base, 'sources/index.scss') 129 | const content = fs.readFileSync(absPath, 'utf8') 130 | td.verify(depResolver.register(absPath, content)) 131 | done() 132 | }) 133 | }) 134 | }) 135 | 136 | describe('with base path', () => { 137 | beforeAll(done => { 138 | bs = create( 139 | config.extend({ 140 | basePath: '/path/to/base/' 141 | }), 142 | { 143 | open: false, 144 | logLevel: 'silent' 145 | }, 146 | createDepCache() 147 | ) 148 | 149 | bs.emitter.on('init', done) 150 | }) 151 | 152 | afterAll(() => { 153 | bs.exit() 154 | }) 155 | 156 | it('allows to set base path of assets', done => { 157 | http.get( 158 | reqTo('/path/to/base/sources/index.html'), 159 | waitForData((res, data) => { 160 | expect(res.statusCode).toBe(200) 161 | expectDataToBeFile(data, 'sources/index.html') 162 | done() 163 | }) 164 | ) 165 | }) 166 | 167 | it('returns not found message if the req does not follow the base path', done => { 168 | http.get(reqTo('/sources/index.html'), res => { 169 | expect(res.statusCode).toBe(404) 170 | done() 171 | }) 172 | }) 173 | 174 | it('redirects if it requests to the root of base path', done => { 175 | http.get(reqTo('/path/to/base'), res => { 176 | expect(res.statusCode).toBe(301) 177 | expect(res.headers.location).toBe('/path/to/base/') 178 | done() 179 | }) 180 | }) 181 | }) 182 | 183 | describe('with proxy', () => { 184 | let proxy 185 | beforeAll(done => { 186 | const proxyConfig = Config.create( 187 | { 188 | input: 'sources', 189 | output: 'dist', 190 | dev: { 191 | proxy: { 192 | '/': { 193 | target: 'http://localhost:61234/', 194 | logLevel: 'silent' 195 | } 196 | }, 197 | port: 51234 198 | } 199 | }, 200 | {}, 201 | { base } 202 | ) 203 | 204 | proxy = create( 205 | proxyConfig, 206 | { 207 | open: false, 208 | logLevel: 'silent' 209 | }, 210 | createDepCache() 211 | ) 212 | 213 | bs = create( 214 | config.extend({ 215 | port: 61234 216 | }), 217 | { 218 | open: false, 219 | logLevel: 'silent' 220 | }, 221 | createDepCache() 222 | ) 223 | 224 | const cb = createWaitCallback(2, done) 225 | proxy.emitter.on('init', cb) 226 | bs.emitter.on('init', cb) 227 | }) 228 | 229 | afterAll(() => { 230 | proxy.exit() 231 | bs.exit() 232 | }) 233 | 234 | it('proxies requests besed on proxy option', done => { 235 | http.get( 236 | reqTo('/sources/index.html'), 237 | waitForData((res, data) => { 238 | expect(res.statusCode).toBe(200) 239 | expectDataToBeFile(data, 'sources/index.html') 240 | done() 241 | }) 242 | ) 243 | }) 244 | 245 | it('prioritize self-resolved contents than proxy', done => { 246 | http.get( 247 | reqTo('/sources/index.js'), 248 | waitForData((res, data) => { 249 | expect(res.statusCode).toBe(200) 250 | expectDataToBeFile(data, 'sources/sources/index.js') 251 | done() 252 | }) 253 | ) 254 | }) 255 | }) 256 | 257 | describe('with base path and proxy', () => { 258 | let proxy 259 | beforeAll(done => { 260 | const proxyConfig = Config.create( 261 | { 262 | input: 'sources', 263 | output: 'dist', 264 | dev: { 265 | proxy: { 266 | '/': { 267 | target: 'http://localhost:61234/', 268 | logLevel: 'silent' 269 | } 270 | }, 271 | port: 51234, 272 | basePath: '/assets' 273 | } 274 | }, 275 | {}, 276 | { base } 277 | ) 278 | 279 | proxy = create( 280 | proxyConfig, 281 | { 282 | open: false, 283 | logLevel: 'silent' 284 | }, 285 | createDepCache() 286 | ) 287 | 288 | bs = create( 289 | config.extend({ 290 | port: 61234 291 | }), 292 | { 293 | open: false, 294 | logLevel: 'silent' 295 | }, 296 | createDepCache() 297 | ) 298 | 299 | const cb = createWaitCallback(2, done) 300 | proxy.emitter.on('init', cb) 301 | bs.emitter.on('init', cb) 302 | }) 303 | 304 | afterAll(() => { 305 | proxy.exit() 306 | bs.exit() 307 | }) 308 | 309 | it('fallbacks to proxy if the request does not match base path', done => { 310 | http.get( 311 | reqTo('/sources/index.html'), 312 | waitForData((res, data) => { 313 | expect(res.statusCode).toBe(200) 314 | expectDataToBeFile(data, 'sources/index.html') 315 | done() 316 | }) 317 | ) 318 | }) 319 | }) 320 | 321 | describe('cache', () => { 322 | let callCount, revertUpdate 323 | beforeEach(done => { 324 | callCount = 0 325 | 326 | const cacheConfig = Config.create( 327 | { 328 | input: '', 329 | output: 'dist', 330 | rules: { 331 | js: 'js' 332 | }, 333 | dev: { 334 | port: 51234 335 | } 336 | }, 337 | { 338 | js: stream => { 339 | return stream.pipe( 340 | transform((file, encoding, callback) => { 341 | callCount += 1 342 | const source = file.contents.toString() 343 | file.contents = Buffer.from('**transformed**\n' + source) 344 | callback(null, file) 345 | }) 346 | ) 347 | } 348 | }, 349 | { base } 350 | ) 351 | 352 | bs = create( 353 | cacheConfig, 354 | { 355 | open: false, 356 | logLevel: 'silent' 357 | }, 358 | createDepCache() 359 | ) 360 | 361 | bs.emitter.on('init', done) 362 | }) 363 | 364 | afterEach(() => { 365 | if (revertUpdate) { 366 | revertUpdate() 367 | revertUpdate = null 368 | } 369 | 370 | bs.exit() 371 | }) 372 | 373 | it('caches response data and not transform multiple times', done => { 374 | http.get( 375 | reqTo('/sources/index.js'), 376 | waitForData(() => { 377 | expect(callCount).toBe(1) 378 | 379 | http.get( 380 | reqTo('/sources/index.js'), 381 | waitForData(() => { 382 | expect(callCount).toBe(1) 383 | done() 384 | }) 385 | ) 386 | }) 387 | ) 388 | }) 389 | 390 | it('updates the cache when the requested file was updated', done => { 391 | http.get( 392 | reqTo('/sources/index.js'), 393 | waitForData(() => { 394 | expect(callCount).toBe(1) 395 | revertUpdate = updateFile('sources/index.js', 'alert("Hello")') 396 | 397 | http.get( 398 | reqTo('/sources/index.js'), 399 | waitForData((res, data) => { 400 | expect(callCount).toBe(2) 401 | expect(data.toString()).toBe('**transformed**\nalert("Hello")') 402 | done() 403 | }) 404 | ) 405 | }) 406 | ) 407 | }) 408 | }) 409 | }) 410 | -------------------------------------------------------------------------------- /test/specs/loggers/build-logger.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const td = require('testdouble') 4 | const BuildLogger = require('../../../lib/loggers/build-logger') 5 | 6 | describe('Build Logger', () => { 7 | it('should log that a build is started', () => { 8 | const console = { log: td.function() } 9 | const logger = new BuildLogger( 10 | { 11 | base: '/path/to/base', 12 | input: '/path/to/base/src', 13 | output: '/path/to/base/dist' 14 | }, 15 | { 16 | console 17 | } 18 | ) 19 | 20 | logger.start() 21 | 22 | td.verify(console.log('Building src -> dist')) 23 | }) 24 | 25 | it('should log that a build is finished', () => { 26 | const console = { log: td.function() } 27 | const now = td.function() 28 | 29 | td.when(now()).thenReturn(0, 12345) 30 | 31 | const logger = new BuildLogger( 32 | { 33 | base: '/base', 34 | input: '/base/to/src', 35 | output: '/base/to/dist' 36 | }, 37 | { 38 | console, 39 | now 40 | } 41 | ) 42 | 43 | logger.start() 44 | td.verify(console.log('Building to/src -> to/dist')) 45 | 46 | logger.finish() 47 | td.verify(console.log('Finished in 12.34s')) 48 | }) 49 | 50 | it('should accurate time when its unit is minutes', () => { 51 | const console = { log: td.function() } 52 | const now = td.function() 53 | 54 | td.when(now()).thenReturn(0, 120000) 55 | 56 | const logger = new BuildLogger( 57 | { 58 | base: '/base', 59 | input: '/base/to/src', 60 | output: '/base/to/dist' 61 | }, 62 | { 63 | console, 64 | now 65 | } 66 | ) 67 | 68 | logger.start() 69 | td.verify(console.log('Building to/src -> to/dist')) 70 | 71 | logger.finish() 72 | td.verify(console.log('Finished in 2m')) 73 | }) 74 | 75 | it('should log errors', () => { 76 | const console = { 77 | log: td.function(), 78 | error: td.function() 79 | } 80 | 81 | const logger = new BuildLogger( 82 | { 83 | base: '/path', 84 | input: '/path/src', 85 | output: '/path/dist' 86 | }, 87 | { 88 | console 89 | } 90 | ) 91 | 92 | logger.start() 93 | logger.error(new Error('Test 1')) 94 | logger.error(new Error('Test 2')) 95 | logger.finish() 96 | 97 | td.verify(console.error('Test 1'), { times: 1 }) 98 | td.verify(console.error('Test 2'), { times: 1 }) 99 | td.verify(console.log('Finished with 2 error(s)')) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/specs/loggers/dev-logger.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const td = require('testdouble') 4 | const color = require('../../../lib/color') 5 | const DevLogger = require('../../../lib/loggers/dev-logger') 6 | const externalIp = require('../../../lib/util').getIp() 7 | 8 | describe('Dev Logger', () => { 9 | it('should log that dev server starts', () => { 10 | const console = { log: td.function() } 11 | const logger = new DevLogger( 12 | {}, 13 | { 14 | console 15 | } 16 | ) 17 | 18 | logger.startDevServer(8080, externalIp) 19 | td.verify( 20 | console.log( 21 | 'Houl dev server is running at:\nLocal: http://localhost:8080' 22 | ) 23 | ) 24 | externalIp.forEach(ip => { 25 | td.verify(console.log(`External: http://${ip}:8080`)) 26 | }) 27 | }) 28 | 29 | it('should log that a source file is added', () => { 30 | const console = { log: td.function() } 31 | const logger = new DevLogger( 32 | { 33 | input: '/path/to/input' 34 | }, 35 | { 36 | console 37 | } 38 | ) 39 | 40 | logger.addFile('/path/to/input/js/index.js') 41 | 42 | td.verify(console.log(color.yellow('ADDED') + ' /js/index.js')) 43 | }) 44 | 45 | it('should log that a source file is updated', () => { 46 | const console = { log: td.function() } 47 | const logger = new DevLogger( 48 | { 49 | input: '/path/to/input' 50 | }, 51 | { 52 | console 53 | } 54 | ) 55 | 56 | logger.updateFile('/path/to/input/js/index.js') 57 | 58 | td.verify(console.log(color.yellow('UPDATED') + ' /js/index.js')) 59 | }) 60 | 61 | it('should log that there is a GET request', () => { 62 | const console = { log: td.function() } 63 | const logger = new DevLogger( 64 | {}, 65 | { 66 | console 67 | } 68 | ) 69 | 70 | logger.getFile('/js/index.js') 71 | 72 | td.verify(console.log(color.green('GET') + ' /js/index.js')) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/specs/loggers/watch-logger.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const td = require('testdouble') 4 | const color = require('../../../lib/color') 5 | const WatchLogger = require('../../../lib/loggers/watch-logger') 6 | 7 | describe('Watch Logger', () => { 8 | it('should log watching source directory', () => { 9 | const console = { log: td.function() } 10 | const logger = new WatchLogger( 11 | { 12 | base: '/path/to', 13 | input: '/path/to/input' 14 | }, 15 | { 16 | console 17 | } 18 | ) 19 | 20 | logger.startWatching() 21 | td.verify(console.log('Houl is watching the source directory: input/')) 22 | }) 23 | 24 | it('should log that a source file was added', () => { 25 | const console = { log: td.function() } 26 | const logger = new WatchLogger( 27 | { 28 | base: '/path/to', 29 | input: '/path/to/input' 30 | }, 31 | { 32 | console 33 | } 34 | ) 35 | 36 | logger.addFile('/path/to/input/index.html') 37 | td.verify(console.log(color.yellow('ADDED') + ' /input/index.html')) 38 | }) 39 | 40 | it('should log that a source file was updated', () => { 41 | const console = { log: td.function() } 42 | const logger = new WatchLogger( 43 | { 44 | base: '/path/to', 45 | input: '/path/to/input' 46 | }, 47 | { 48 | console 49 | } 50 | ) 51 | 52 | logger.updateFile('/path/to/input/index.html') 53 | td.verify(console.log(color.yellow('UPDATED') + ' /input/index.html')) 54 | }) 55 | 56 | it('should log the dest path of the build', () => { 57 | const console = { log: td.function() } 58 | const logger = new WatchLogger( 59 | { 60 | base: '/path/to', 61 | output: '/path/to/output' 62 | }, 63 | { 64 | console 65 | } 66 | ) 67 | 68 | logger.writeFile('/path/to/output/index.html') 69 | td.verify(console.log(color.cyan('WROTE') + ' /output/index.html')) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/specs/models/config.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const normalize = require('normalize-path') 4 | const Config = require('../../../lib/models/config') 5 | 6 | describe('Config model', () => { 7 | it('resolves input/output paths besed on base path', () => { 8 | const c = Config.create( 9 | { 10 | input: 'path/to/src', 11 | output: 'to/dist/' 12 | }, 13 | {}, 14 | { 15 | base: '/path/to/base' 16 | } 17 | ) 18 | expect(c.input).toBePath('/path/to/base/path/to/src') 19 | expect(c.output).toBePath('/path/to/base/to/dist') 20 | }) 21 | 22 | it('retain exclude field', () => { 23 | const c = Config.create( 24 | { 25 | exclude: '**/_*' 26 | }, 27 | {} 28 | ) 29 | 30 | expect(c.exclude.length).toBe(1) 31 | expect(c.exclude[0]).toBePath('**/_*') 32 | }) 33 | 34 | it('creates vinyl input', () => { 35 | const c = Config.create( 36 | { 37 | input: 'src' 38 | }, 39 | {}, 40 | { 41 | base: '/path/to' 42 | } 43 | ) 44 | expect(c.vinylInput.length).toBe(1) 45 | expect(c.vinylInput[0]).toBePath('/path/to/src/**/*') 46 | }) 47 | 48 | it('includes `exclude` pattern into vinyl input', () => { 49 | const c = Config.create( 50 | { 51 | input: 'src', 52 | exclude: '**/_*' 53 | }, 54 | {}, 55 | { 56 | base: '/path/to/' 57 | } 58 | ) 59 | expect(c.vinylInput.length).toBe(2) 60 | expect(c.vinylInput[0]).toBePath('/path/to/src/**/*') 61 | expect(c.vinylInput[1]).toBePath('!**/_*') 62 | }) 63 | 64 | it('filters input pattern', () => { 65 | const c = new Config({ 66 | input: '/path/to/src', 67 | filter: '/**/*.scss' 68 | }) 69 | 70 | expect(c.vinylInput.length).toBe(1) 71 | expect(c.vinylInput[0]).toBePath('/path/to/src/**/*.scss') 72 | }) 73 | 74 | it('includes array formed `exclude` pattern into vinyl input', () => { 75 | const c = Config.create( 76 | { 77 | input: 'src', 78 | exclude: ['**/_*', '**/.DS_Store'] 79 | }, 80 | {}, 81 | { 82 | base: '/path/to/' 83 | } 84 | ) 85 | const input = c.vinylInput 86 | expect(input.length).toBe(3) 87 | expect(input[0]).toBePath('/path/to/src/**/*') 88 | expect(input[1]).toBePath('!**/_*') 89 | expect(input[2]).toBePath('!**/.DS_Store') 90 | }) 91 | 92 | it('isExclude always returns false if `exclude` is empty', () => { 93 | const c = Config.create( 94 | { 95 | input: 'src' 96 | }, 97 | {} 98 | ) 99 | 100 | expect(c.isExclude('/path/to/foo.css')).toBe(false) 101 | expect(c.isExclude('')).toBe(false) 102 | }) 103 | 104 | it('test whether a path matches exclude pattern', () => { 105 | const c = Config.create( 106 | { 107 | input: '/', 108 | exclude: '**/_*' 109 | }, 110 | {} 111 | ) 112 | 113 | expect(c.isExclude('/path/to/file.js')).toBe(false) 114 | expect(c.isExclude('path/to/_internal.js')).toBe(true) 115 | }) 116 | 117 | it('should not match ancestor path of the input directory for exclude', () => { 118 | const c = Config.create( 119 | { 120 | input: '/path/to/src', 121 | exclude: '**/to/**' 122 | }, 123 | {} 124 | ) 125 | 126 | expect(c.isExclude('/path/to/src/foo/bar.js')).toBe(false) 127 | expect(c.isExclude('/path/to/src/to/foo/bar.js')).toBe(true) 128 | expect(c.isExclude('path/to/relative.js')).toBe(true) 129 | }) 130 | 131 | it('loads tasks', () => { 132 | const c = Config.create( 133 | { 134 | rules: { 135 | js: 'foo', 136 | scss: { 137 | task: 'bar' 138 | } 139 | } 140 | }, 141 | { 142 | foo: () => 'foo', 143 | bar: () => 'bar' 144 | } 145 | ) 146 | expect(c.rules.js.task()).toBe('foo') 147 | expect(c.rules.scss.task()).toBe('bar') 148 | }) 149 | 150 | it('add inputExt/outputExt in each rule object', () => { 151 | const c = Config.create( 152 | { 153 | rules: { 154 | js: 'foo', 155 | scss: { 156 | task: 'bar', 157 | outputExt: 'css' 158 | } 159 | } 160 | }, 161 | { 162 | foo: () => 'foo', 163 | bar: () => 'bar' 164 | } 165 | ) 166 | expect(c.rules.js.inputExt).toBe('js') 167 | expect(c.rules.js.outputExt).toBe('js') 168 | expect(c.rules.scss.inputExt).toBe('scss') 169 | expect(c.rules.scss.outputExt).toBe('css') 170 | }) 171 | 172 | it('merges rules of preset', () => { 173 | const preset = Config.create( 174 | { 175 | rules: { 176 | scss: { 177 | task: 'baz', 178 | outputExt: 'css' 179 | }, 180 | png: 'qux' 181 | } 182 | }, 183 | { 184 | baz: () => 'baz', 185 | qux: () => 'qux' 186 | } 187 | ) 188 | 189 | const c = Config.create( 190 | { 191 | rules: { 192 | js: 'foo', 193 | scss: 'bar' 194 | } 195 | }, 196 | { 197 | foo: () => 'foo', 198 | bar: () => 'bar' 199 | }, 200 | { 201 | preset 202 | } 203 | ) 204 | 205 | expect(c.rules.js.task()).toBe('foo') 206 | expect(c.rules.scss.task()).toBe('bar') 207 | expect(c.rules.scss.outputExt).toBe('scss') 208 | expect(c.rules.png.task()).toBe('qux') 209 | }) 210 | 211 | it('merges rules fields with task name', () => { 212 | const preset = Config.create( 213 | { 214 | rules: { 215 | js: 'script' 216 | } 217 | }, 218 | { 219 | script: () => 'preset' 220 | } 221 | ) 222 | 223 | const c = Config.create( 224 | { 225 | rules: { 226 | js: { 227 | exclude: '_*' 228 | } 229 | } 230 | }, 231 | { 232 | script: () => 'child' 233 | }, 234 | { 235 | preset 236 | } 237 | ) 238 | 239 | expect(c.rules.js.task()).toBe('preset') 240 | expect(c.rules.js.exclude).toEqual(['_*']) 241 | }) 242 | 243 | it('concats excludes field on rules', () => { 244 | const preset = Config.create( 245 | { 246 | rules: { 247 | js: { 248 | task: 'script', 249 | exclude: '_*' 250 | } 251 | } 252 | }, 253 | { 254 | script: () => 'preset' 255 | } 256 | ) 257 | 258 | const c = Config.create( 259 | { 260 | rules: { 261 | js: { 262 | exclude: ['test.js'] 263 | } 264 | } 265 | }, 266 | {}, 267 | { 268 | preset 269 | } 270 | ) 271 | 272 | expect(c.rules.js.exclude).toEqual(['_*', 'test.js']) 273 | }) 274 | 275 | it('merges progeny options on rules', () => { 276 | const preset = Config.create( 277 | { 278 | rules: { 279 | js: { 280 | task: 'script', 281 | progeny: { 282 | regexp: /foo/, 283 | altPaths: ['/path/foo'] 284 | } 285 | } 286 | } 287 | }, 288 | { 289 | script: () => 'preset' 290 | } 291 | ) 292 | 293 | const c = Config.create( 294 | { 295 | rules: { 296 | js: { 297 | progeny: { 298 | regexp: /bar/, 299 | altPaths: ['/path/bar'], 300 | skipComments: true 301 | } 302 | } 303 | } 304 | }, 305 | {}, 306 | { 307 | preset 308 | } 309 | ) 310 | 311 | const resolved = c.rules.js.progeny 312 | expect(resolved.regexp).toEqual(/bar/) 313 | expect(resolved.altPaths).toEqual(['/path/foo', '/path/bar']) 314 | expect(resolved.skipComments).toBe(true) 315 | }) 316 | 317 | it('finds rule by input file path', () => { 318 | const c = Config.create( 319 | { 320 | rules: { 321 | js: 'foo', 322 | scss: 'bar' 323 | } 324 | }, 325 | { 326 | foo: () => 'foo', 327 | bar: () => 'bar' 328 | } 329 | ) 330 | 331 | let rule = c.findRuleByInput('path/to/test.js') 332 | expect(rule.task()).toBe('foo') 333 | rule = c.findRuleByInput('path/to/test.scss') 334 | expect(rule.task()).toBe('bar') 335 | rule = c.findRuleByInput('path/to/test.js.html') 336 | expect(rule).toBe(null) 337 | }) 338 | 339 | it('excludes matched input file path for rule', () => { 340 | const c = Config.create( 341 | { 342 | rules: { 343 | js: { 344 | task: 'foo', 345 | exclude: '**/vendor/**' 346 | } 347 | } 348 | }, 349 | { 350 | foo: () => 'foo' 351 | } 352 | ) 353 | 354 | let rule = c.findRuleByInput('path/to/test.js') 355 | expect(rule.task()).toBe('foo') 356 | rule = c.findRuleByInput('path/to/vendor/test.js') 357 | expect(rule).toBe(null) 358 | }) 359 | 360 | it('finds rule by output file path', () => { 361 | function exists(pathname) { 362 | return ( 363 | ['path/to/test.js', 'path/to/test.scss'].indexOf(normalize(pathname)) >= 364 | 0 365 | ) 366 | } 367 | 368 | const c = Config.create( 369 | { 370 | input: '', 371 | output: '', 372 | rules: { 373 | js: 'foo', 374 | scss: { 375 | task: 'bar', 376 | outputExt: 'css' 377 | } 378 | } 379 | }, 380 | { 381 | foo: () => 'foo', 382 | bar: () => 'bar' 383 | } 384 | ) 385 | 386 | let rule = c.findRuleByOutput('path/to/test.js', exists) 387 | expect(rule.task()).toBe('foo') 388 | 389 | rule = c.findRuleByOutput('path/to/test.css', exists) 390 | expect(rule.task()).toBe('bar') 391 | 392 | // There is no matched rule but file is found -> empty rule 393 | rule = c.findRuleByOutput('path/to/test.scss', exists) 394 | expect(rule.isEmpty).toBe(true) 395 | 396 | // File is not found -> null 397 | rule = c.findRuleByOutput('path/to/not-found.js', exists) 398 | expect(rule).toBe(null) 399 | }) 400 | 401 | it('excludes matched output file path for rule', () => { 402 | function exists(pathname) { 403 | return ( 404 | [ 405 | 'path/to/test_1.scss', 406 | 'path/to/vendor/test.css', 407 | 'path/to/vendor/test.scss', 408 | 'path/to/test_2.less' 409 | ].indexOf(normalize(pathname)) >= 0 410 | ) 411 | } 412 | 413 | const c = Config.create( 414 | { 415 | input: '', 416 | output: '', 417 | rules: { 418 | scss: { 419 | task: 'foo', 420 | outputExt: 'css', 421 | exclude: '**/vendor/**' 422 | }, 423 | css: 'bar', 424 | less: { 425 | task: 'baz', 426 | outputExt: 'css' 427 | } 428 | } 429 | }, 430 | { 431 | foo: () => 'foo', 432 | bar: () => 'bar', 433 | baz: () => 'baz' 434 | } 435 | ) 436 | 437 | let rule = c.findRuleByOutput('path/to/test_1.css', exists) 438 | expect(rule.task()).toBe('foo') 439 | 440 | // Ignored by scss rule 441 | rule = c.findRuleByOutput('path/to/vendor/test.css', exists) 442 | expect(rule.task()).toBe('bar') 443 | 444 | // Should not match if possible input file is not found 445 | rule = c.findRuleByOutput('path/to/test_2.css', exists) 446 | expect(rule.task()).toBe('baz') 447 | }) 448 | 449 | it('resolves proxy config', () => { 450 | const proxy = { 451 | '/foo': 'http://foo.com/', 452 | '/bar': { 453 | target: 'https://bar.com/', 454 | secure: true 455 | } 456 | } 457 | 458 | const c = Config.create( 459 | { 460 | dev: { proxy } 461 | }, 462 | {} 463 | ) 464 | 465 | expect(c.proxy).toEqual([ 466 | { 467 | context: '/foo', 468 | config: { 469 | target: 'http://foo.com/' 470 | } 471 | }, 472 | { 473 | context: '/bar', 474 | config: { 475 | target: 'https://bar.com/', 476 | secure: true 477 | } 478 | } 479 | ]) 480 | }) 481 | 482 | it('provides an empty array as proxy if dev.proxy is not specified', () => { 483 | const c = Config.create({}, {}) 484 | 485 | expect(c.proxy).toEqual([]) 486 | }) 487 | 488 | it('resolves port config', () => { 489 | const c = Config.create( 490 | { 491 | dev: { port: 51234 } 492 | }, 493 | {} 494 | ) 495 | 496 | expect(c.port).toBe(51234) 497 | }) 498 | 499 | it('provides 3000 as a default port number', () => { 500 | const c = Config.create({}, {}) 501 | expect(c.port).toBe(3000) 502 | }) 503 | 504 | it('resolves basePath config', () => { 505 | const c = Config.create( 506 | { 507 | dev: { basePath: '/path/to/base' } 508 | }, 509 | {} 510 | ) 511 | 512 | expect(c.basePath).toBe('/path/to/base') 513 | }) 514 | 515 | it("provides '/' as a default base path", () => { 516 | const c = Config.create({}, {}) 517 | expect(c.basePath).toBe('/') 518 | }) 519 | 520 | it('extends itself with the provided object', () => { 521 | const c = Config.create( 522 | { 523 | input: 'src', 524 | output: 'dist' 525 | }, 526 | {}, 527 | { base: '/' } 528 | ) 529 | 530 | expect(c.input).toBe('/src') 531 | expect(c.output).toBe('/dist') 532 | expect(c.filter).toBe('**/*') 533 | const e = c.extend({ filter: 'test/**/*' }) 534 | expect(e.input).toBe('/src') 535 | expect(e.output).toBe('/dist') 536 | expect(e.filter).toBe('test/**/*') 537 | }) 538 | 539 | it('ignores null or undefined value for extend', () => { 540 | const c = Config.create( 541 | { 542 | input: 'src', 543 | output: 'dist' 544 | }, 545 | {}, 546 | { 547 | base: '/' 548 | } 549 | ).extend({ 550 | filter: 'test/**/*' 551 | }) 552 | 553 | expect(c.input).toBe('/src') 554 | expect(c.output).toBe('/dist') 555 | expect(c.filter).toBe('test/**/*') 556 | const e1 = c.extend({ filter: null }) 557 | expect(e1.input).toBe('/src') 558 | expect(e1.output).toBe('/dist') 559 | expect(e1.filter).toBe('test/**/*') 560 | const e2 = c.extend({ filter: undefined }) 561 | expect(e2.input).toBe('/src') 562 | expect(e2.output).toBe('/dist') 563 | expect(e2.filter).toBe('test/**/*') 564 | }) 565 | }) 566 | -------------------------------------------------------------------------------- /test/specs/models/rule.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Rule = require('../../../lib/models/rule') 4 | 5 | describe('Rule model', () => { 6 | it('has correct properties', () => { 7 | const r = Rule.create( 8 | { 9 | task: 'foo', 10 | outputExt: 'css', 11 | exclude: '**/vendor/**', 12 | progeny: { 13 | rootPath: 'path/to/root' 14 | } 15 | }, 16 | 'scss', 17 | { 18 | foo: () => 'foo', 19 | bar: () => 'bar' 20 | } 21 | ) 22 | 23 | expect(r.taskName).toBe('foo') 24 | expect(r.task()).toBe('foo') 25 | expect(r.inputExt).toBe('scss') 26 | expect(r.outputExt).toBe('css') 27 | expect(r.exclude).toEqual(['**/vendor/**']) 28 | expect(r.progeny).toEqual({ 29 | extension: 'scss', 30 | rootPath: 'path/to/root' 31 | }) 32 | }) 33 | 34 | it('accepts array formed exclude', () => { 35 | const r = Rule.create( 36 | { 37 | task: 'foo', 38 | exclude: ['**/vendor/**', '**/.DS_Store'] 39 | }, 40 | 'js', 41 | { 42 | foo: () => 'foo' 43 | } 44 | ) 45 | 46 | expect(r.exclude).toEqual(['**/vendor/**', '**/.DS_Store']) 47 | }) 48 | 49 | it('deals with string format', () => { 50 | const r = Rule.create('foo', 'js', { 51 | foo: () => 'foo' 52 | }) 53 | expect(r.taskName).toBe('foo') 54 | expect(r.task()).toBe('foo') 55 | }) 56 | 57 | it('asserts task is appear', () => { 58 | expect(() => { 59 | Rule.create( 60 | { 61 | task: 'foo' 62 | }, 63 | 'js', 64 | { 65 | bar: () => 'bar' 66 | } 67 | ) 68 | }).toThrowError('Task "foo" is not defined') 69 | }) 70 | 71 | it('provides options to the task', () => { 72 | const r = Rule.create( 73 | { 74 | task: 'foo', 75 | options: { 76 | test: 'success' 77 | } 78 | }, 79 | 'js', 80 | { 81 | foo: (_, options) => options.test 82 | } 83 | ) 84 | expect(r.task()).toBe('success') 85 | }) 86 | 87 | it('converts output path to input path', () => { 88 | const r = Rule.create( 89 | { 90 | task: 'foo', 91 | outputExt: 'css' 92 | }, 93 | 'scss', 94 | { 95 | foo: () => 'foo' 96 | } 97 | ) 98 | 99 | expect(r.getInputPath('path/to/test.css')).toBe('path/to/test.scss') 100 | }) 101 | 102 | it('converts input path to output path', () => { 103 | const r = Rule.create( 104 | { 105 | task: 'foo', 106 | outputExt: 'css' 107 | }, 108 | 'scss', 109 | { 110 | foo: () => 'foo' 111 | } 112 | ) 113 | 114 | expect(r.getOutputPath('path/to/test.scss')).toBe('path/to/test.css') 115 | }) 116 | 117 | it('checks whether the given path is excluded or not', () => { 118 | const r = Rule.create( 119 | { 120 | task: 'foo', 121 | exclude: ['**/vendor/**', '**/_*'] 122 | }, 123 | 'js', 124 | { 125 | foo: () => 'foo' 126 | } 127 | ) 128 | 129 | expect(r.isExclude('src/js/vendor/index.js')).toBe(true) 130 | expect(r.isExclude('src/js/_hidden.js')).toBe(true) 131 | expect(r.isExclude('src/js/index.js')).toBe(false) 132 | }) 133 | 134 | it('throws a task not found', () => { 135 | expect(() => { 136 | Rule.create( 137 | { 138 | task: 'foo' 139 | }, 140 | 'js', 141 | { 142 | bar: () => 'bar' 143 | } 144 | ) 145 | }).toThrowError(/Task "foo" is not defined/) 146 | }) 147 | 148 | it('throws a task in merged rule not found', () => { 149 | const parent = Rule.create( 150 | { 151 | task: 'foo' 152 | }, 153 | 'js', 154 | { 155 | foo: () => 'foo' 156 | } 157 | ) 158 | 159 | expect(() => { 160 | Rule.create( 161 | { 162 | task: 'foo' 163 | }, 164 | 'js', 165 | { 166 | bar: () => 'bar' 167 | }, 168 | parent 169 | ) 170 | }).toThrowError(/Task "foo" is not defined/) 171 | }) 172 | 173 | it('accepts inline task', () => { 174 | const r = Rule.create( 175 | { 176 | task: () => 'pass' 177 | }, 178 | 'js', 179 | {} 180 | ) 181 | 182 | expect(r.taskName).toBe('') 183 | expect(r.task()).toBe('pass') 184 | }) 185 | 186 | describe('Empty rule', () => { 187 | const empty = Rule.empty 188 | 189 | it('has its flag', () => { 190 | expect(empty.isEmpty).toBe(true) 191 | }) 192 | 193 | it('passes output on getInputPath', () => { 194 | expect(empty.getInputPath('/path/to/test.js')).toBe('/path/to/test.js') 195 | }) 196 | 197 | it('passes input on getOutputPath', () => { 198 | expect(empty.getOutputPath('/path/to/test.js')).toBe('/path/to/test.js') 199 | }) 200 | 201 | it('always treats that the input path is not excluded', () => { 202 | expect(empty.isExclude('/path/to/something')).toBe(false) 203 | }) 204 | 205 | it('has the task that does nothing', () => { 206 | expect(empty.task('foobar')).toBe('foobar') 207 | }) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /test/specs/task-stream.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const td = require('testdouble') 4 | 5 | const Config = require('../../lib/models/config') 6 | const taskStream = require('../../lib/task-stream') 7 | 8 | const helpers = require('../helpers') 9 | const vinyl = helpers.vinyl 10 | const source = helpers.source 11 | const transform = helpers.transform 12 | const assertStream = helpers.assertStream 13 | 14 | describe('ProcessTask Stream', () => { 15 | it('passes through files that does not match any rules', done => { 16 | const config = Config.create( 17 | { 18 | rules: { 19 | js: 'js' 20 | } 21 | }, 22 | { 23 | js: stream => 24 | stream.pipe( 25 | transform((file, encoding, done) => { 26 | done(new Error('Unexpected')) 27 | }) 28 | ) 29 | } 30 | ) 31 | 32 | source([vinyl({ path: 'test.css', contents: '' })]) 33 | .pipe(taskStream(config)) 34 | .pipe(assertStream([vinyl({ path: 'test.css', contents: '' })])) 35 | .on('finish', done) 36 | }) 37 | 38 | it('transforms file by matched task', done => { 39 | const config = Config.create( 40 | { 41 | rules: { 42 | es6: { 43 | task: 'js', 44 | outputExt: 'js' 45 | }, 46 | scss: { 47 | task: 'css', 48 | outputExt: 'css' 49 | } 50 | } 51 | }, 52 | { 53 | js: stream => 54 | stream.pipe( 55 | transform((file, encoding, done) => { 56 | file.contents = Buffer.from('es6: ' + file.contents) 57 | done(null, file) 58 | }) 59 | ), 60 | css: stream => 61 | stream.pipe( 62 | transform((file, encoding, done) => { 63 | file.contents = Buffer.from('scss: ' + file.contents) 64 | done(null, file) 65 | }) 66 | ) 67 | } 68 | ) 69 | 70 | source([ 71 | vinyl({ path: 'test.es6', contents: 'const test = "es6"' }), 72 | vinyl({ path: 'test.scss', contents: '.foo {}' }) 73 | ]) 74 | .pipe(taskStream(config)) 75 | .pipe( 76 | assertStream([ 77 | vinyl({ path: 'test.js', contents: 'es6: const test = "es6"' }), 78 | vinyl({ path: 'test.css', contents: 'scss: .foo {}' }) 79 | ]) 80 | ) 81 | .on('finish', done) 82 | }) 83 | 84 | it('ignores files that is matched with exclude option', done => { 85 | const config = Config.create( 86 | { 87 | rules: { 88 | es6: { 89 | task: 'js', 90 | outputExt: 'js', 91 | exclude: '**/vendor/**' 92 | } 93 | } 94 | }, 95 | { 96 | js: stream => 97 | stream.pipe( 98 | transform((file, encoding, done) => { 99 | file.contents = Buffer.from('es6: ' + file.contents) 100 | done(null, file) 101 | }) 102 | ) 103 | } 104 | ) 105 | 106 | source([ 107 | vinyl({ path: 'test.es6', contents: 'const test = "test"' }), 108 | vinyl({ path: 'vendor/test.es6', contents: 'const test = "vendor"' }) 109 | ]) 110 | .pipe(taskStream(config)) 111 | .pipe( 112 | assertStream([ 113 | vinyl({ path: 'test.js', contents: 'es6: const test = "test"' }), 114 | vinyl({ path: 'vendor/test.es6', contents: 'const test = "vendor"' }) 115 | ]) 116 | ) 117 | .on('finish', done) 118 | }) 119 | 120 | it('transforms extname after executing task', done => { 121 | let called = false 122 | const config = Config.create( 123 | { 124 | rules: { 125 | es6: { 126 | task: 'js', 127 | outputExt: 'js' 128 | } 129 | } 130 | }, 131 | { 132 | js: stream => 133 | stream.pipe( 134 | transform((file, encoding, done) => { 135 | expect(file.extname).toBe('.es6') 136 | called = true 137 | done(null, file) 138 | }) 139 | ) 140 | } 141 | ) 142 | 143 | source([vinyl({ path: 'test.es6' })]) 144 | .pipe(taskStream(config)) 145 | .pipe(assertStream([vinyl({ path: 'test.js' })])) 146 | .on('finish', () => { 147 | expect(called).toBe(true) 148 | done() 149 | }) 150 | }) 151 | 152 | // #19 153 | it('hanldes filtering tasks', done => { 154 | const config = Config.create( 155 | { 156 | rules: { 157 | js: 'js' 158 | } 159 | }, 160 | { 161 | js: stream => 162 | stream.pipe( 163 | transform(function(file, encoding, done) { 164 | if (file.path.indexOf('exclude') < 0) { 165 | this.push(file) 166 | } 167 | done() 168 | }) 169 | ) 170 | } 171 | ) 172 | 173 | source([ 174 | vinyl({ path: 'foo.js' }), 175 | vinyl({ path: 'exclude.js' }), 176 | vinyl({ path: 'bar.js' }) 177 | ]) 178 | .pipe(taskStream(config)) 179 | .pipe( 180 | assertStream([vinyl({ path: 'foo.js' }), vinyl({ path: 'bar.js' })]) 181 | ) 182 | .on('finish', done) 183 | }) 184 | 185 | it('handles task errors', done => { 186 | const config = Config.create( 187 | { 188 | rules: { 189 | js: 'js' 190 | } 191 | }, 192 | { 193 | js: stream => 194 | stream.pipe( 195 | transform((file, encoding, done) => { 196 | done(new Error('Test Error')) 197 | }) 198 | ) 199 | } 200 | ) 201 | 202 | source([vinyl({ path: 'error.js' })]) 203 | .pipe(taskStream(config)) 204 | .on('error', err => { 205 | expect(err).toEqual(new Error('Test Error')) 206 | done() 207 | }) 208 | }) 209 | 210 | it('should teardown internal stream and itself in correct order', done => { 211 | const config = Config.create( 212 | { 213 | rules: { 214 | js: 'js' 215 | } 216 | }, 217 | { 218 | js: stream => 219 | stream.pipe( 220 | transform((file, encoding, done) => { 221 | setTimeout(() => { 222 | done(null, file) 223 | }, 0) 224 | }) 225 | ) 226 | } 227 | ) 228 | 229 | const spy = td.function() 230 | const data = vinyl({ path: 'test.js' }) 231 | 232 | source([data]) 233 | .pipe(taskStream(config)) 234 | .on('data', spy) 235 | .on('error', err => { 236 | throw err 237 | }) 238 | .on('end', () => { 239 | td.verify(spy(data), { times: 1 }) 240 | done() 241 | }) 242 | }) 243 | 244 | it('should update mtime and ctime of a file', done => { 245 | const config = Config.create({}, {}) 246 | 247 | const time = new Date() 248 | 249 | const data = vinyl({ 250 | path: 'test.js', 251 | stat: { 252 | atime: time, 253 | mtime: time, 254 | ctime: time 255 | } 256 | }) 257 | 258 | setTimeout(() => { 259 | source([data]) 260 | .pipe(taskStream(config)) 261 | .on('data', data => { 262 | expect(data.stat.atime.getTime()).toBe(time.getTime()) 263 | expect(data.stat.mtime.getTime()).toBeGreaterThan(time.getTime()) 264 | expect(data.stat.ctime.getTime()).toBeGreaterThan(time.getTime()) 265 | done() 266 | }) 267 | }, 0) 268 | }) 269 | }) 270 | -------------------------------------------------------------------------------- /test/specs/util.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const util = require('../../lib/util') 4 | 5 | describe('Util', () => { 6 | describe('dropWhile', () => { 7 | const isZero = item => item === 0 8 | 9 | it('works', () => { 10 | expect(util.dropWhile([0, 0, 1, 2, 0, 3], isZero)).toEqual([1, 2, 0, 3]) 11 | }) 12 | 13 | it('keeps original if not matched', () => { 14 | const list = [1, 2, 3, 4] 15 | expect(util.dropWhile(list, isZero)).toEqual(list) 16 | }) 17 | 18 | it('drops all if matched all', () => { 19 | expect(util.dropWhile([0, 0, 0], isZero)).toEqual([]) 20 | }) 21 | }) 22 | 23 | describe('filterProps', () => { 24 | const isNotZero = item => item !== 0 25 | 26 | it('filters object properties', () => { 27 | const obj = { 28 | foo: 0, 29 | bar: 1, 30 | baz: 'str' 31 | } 32 | expect(util.filterProps(obj, isNotZero)).toEqual({ 33 | bar: 1, 34 | baz: 'str' 35 | }) 36 | }) 37 | }) 38 | }) 39 | --------------------------------------------------------------------------------