├── .editorconfig ├── .github ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── appveyor.yml ├── contributing.md ├── lib ├── config.js ├── errors.js ├── index.js └── plugin.js ├── package.json ├── readme.md ├── test ├── _helpers.js ├── app_config.js ├── clean.js ├── compile.js ├── config_errors.js ├── css.js ├── dump_dirs.js ├── environments.js ├── fixtures │ ├── app_config │ │ └── app.js │ ├── app_config_error │ │ └── app.js │ ├── clean │ │ ├── app.js │ │ └── index.html │ ├── compile_error │ │ └── index.sgr │ ├── css │ │ └── main.css │ ├── css_parser │ │ ├── _partial.sss │ │ ├── app.js │ │ └── main.sss │ ├── css_plugin │ │ └── main.css │ ├── dump_dirs │ │ └── views │ │ │ └── index.html │ ├── environments │ │ ├── app.doge.js │ │ ├── app.js │ │ └── index.sgr │ ├── es_modules │ │ ├── doge.js │ │ └── index.js │ ├── html │ │ ├── index.html │ │ └── style.css │ ├── ignores │ │ ├── ignore.html │ │ └── index.html │ ├── loader_custom_ext │ │ ├── app.js │ │ ├── assets │ │ │ └── js │ │ │ │ ├── foo.foo │ │ │ │ └── index.js │ │ ├── fooLoader.js │ │ └── index.html │ ├── loader_source_error │ │ ├── app.js │ │ ├── main.js │ │ └── test.scss │ ├── loaders │ │ ├── app.js │ │ ├── assets │ │ │ └── js │ │ │ │ ├── foo.foo │ │ │ │ └── index.js │ │ └── fooLoader.js │ ├── locals │ │ └── index.jade │ ├── multi │ │ └── index.html │ ├── plugins │ │ ├── after_plugin.js │ │ ├── app.js │ │ ├── custom_output.html │ │ ├── index.html │ │ └── plugin.js │ ├── scope_hoisting │ │ ├── index.js │ │ └── util.js │ ├── skipSpikeProcessing │ │ ├── app.js │ │ ├── assets │ │ │ └── js │ │ │ │ ├── foo.foo │ │ │ │ └── index.js │ │ ├── fooLoader.js │ │ └── index.html │ ├── static │ │ ├── .empty.f │ │ ├── doge.png │ │ ├── foo.wow │ │ └── snargle │ │ │ └── test.json │ ├── static_plugins │ │ ├── app.js │ │ ├── foo.glade │ │ ├── foo.html │ │ ├── foo.spade │ │ └── plugin.js │ ├── sugarml │ │ ├── image.jpg │ │ ├── index.sgr │ │ ├── script.js │ │ └── style.css │ ├── vendor │ │ ├── app.js │ │ ├── index.html │ │ └── keep │ │ │ └── file.js │ └── watch │ │ └── index.html ├── html.js ├── ignores.js ├── js.js ├── loaders.js ├── multi.js ├── new.js ├── plugins.js ├── static.js ├── sugarml.js ├── template.js ├── vendor.js └── watch.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [Makefile] 15 | indent_style = tab 16 | 17 | [{*.md,*.json}] 18 | max_line_length = null 19 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | ## :zap: description 14 | 15 | 22 | 23 | 24 | ## :earth_americas: environment 25 | 26 | 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | public 4 | *.log 5 | test/fixtures/*/public 6 | coverage 7 | .nyc_output 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | contributing.md 3 | .editorconfig 4 | .travis.yml 5 | appveyor.yml 6 | .doclets.yml 7 | .github 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | sudo: false 5 | before_script: 6 | - git config --global user.email "dev@carrotcreative.com" 7 | - git config --global user.name "Carrot Creative" 8 | after_script: 9 | - npm run coveralls 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | License (MIT) 2 | 3 | Copyright (c) 2016 Jeff Escalante 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | nodejs_version: "7" 3 | 4 | install: 5 | - ps: Install-Product node $env:nodejs_version 6 | - npm install 7 | 8 | test_script: 9 | - node --version 10 | - npm --version 11 | - npm test 12 | 13 | build: off 14 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | ## Setup for Contributing 4 | 5 | - Make sure you are running [node.js >= v6](https://nodejs.org/en/). 6 | - Make sure you have an [editorconfig plugin](http://editorconfig.org/#download) installed for your text editor 7 | - Make sure you have a [standard js linter](http://standardjs.com/index.html#usage) installed, tests will not pass if linting fails 8 | - Make sure you are familiar with [ES6](https://medium.com/sons-of-javascript/javascript-an-introduction-to-es6-1819d0d89a0f) 9 | - Make sure you are familiar with [test-driven development](https://www.wikiwand.com/en/Test-driven_development) 10 | 11 | ## Linting & Code Style 12 | 13 | > **NOTE:** This project uses [Standard.js](/feross/standard) to check for code consistency. 14 | 15 | Badly formatted code is; under no circumstance; allowed to hit the remote repo. We have precautions in place to prevent this, namely: 16 | 17 | 1. Automated tests will first lint the code. If the code is badly formatted, the tests will fail. 18 | 2. We use [Husky](/typicode/husky) to run a linting step before every `git commit` 19 | 20 | In the case of badly formatted code, we might ask you to [squash your commits](#squashing-commits) 21 | 22 | ## Writing Tests 23 | 24 | When submitting a pull request, please be sure to add passing tests for any new 25 | logic. 26 | 27 | We have chosen [AVA](/sindresorhus/ava) as our test harness. Since AVA runs tests concurrently, and runs individual test files in parallel ([What's the difference?](http://stackoverflow.com/questions/1050222/concurrency-vs-parallelism-what-is-the-difference)), there are some conventions we've established to prevent 28 | any errors when running asynchronous tests: 29 | 30 | 1. If you have two tests that each test their own fixture, those tests should be placed in different files. 31 | 2. AVA tests are flat in nature. If you feel the need to use `describe`-like nesting functionality (from the likes of Mocha, for example) then create a new test file and pretend it is your `describe` block. 32 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const Joi = require('joi') 3 | const micromatch = require('micromatch') 4 | const union = require('lodash.union') 5 | const merge = require('lodash.merge') 6 | const {accessSync} = require('fs') 7 | const hygienist = require('hygienist-middleware') 8 | const BrowserSyncPlugin = require('browser-sync-webpack-plugin') 9 | const SpikePlugin = require('./plugin') 10 | const SpikeUtils = require('spike-util') 11 | 12 | /** 13 | * @module Config 14 | */ 15 | 16 | /** 17 | * @class Config 18 | * @classdesc Primary configuration for core webpack compiler 19 | * @param {Object} opts - options, documented in the readme 20 | * @param {Spike} project - the current spike instance 21 | */ 22 | module.exports = class Config { 23 | constructor (opts, project) { 24 | if (!opts.root) { 25 | throw new Error('[spike constructor] option "root" is required') 26 | } 27 | // merges API options into app.js options 28 | let allOpts = merge(this.parseAppJs(opts), opts) 29 | this.transformSpikeOptionsToWebpack(this.validateOpts(allOpts)) 30 | const sp = this.plugins.find((p) => p.name === 'spikePlugin') 31 | sp.options.project = project 32 | } 33 | 34 | /** 35 | * Validates spike options, provides defaults where necessary 36 | * @param {Object} opts - spike options object 37 | * @return {Object} validated and fully filled out objects 38 | */ 39 | validateOpts (opts) { 40 | const schema = Joi.object().keys({ 41 | root: Joi.string().required(), 42 | env: Joi.string(), 43 | matchers: Joi.object().default().keys({ 44 | html: Joi.string().default('*(**/)*.html'), 45 | css: Joi.string().default('*(**/)*.css'), 46 | js: Joi.string().default('*(**/)*.js') 47 | }), 48 | postcss: Joi.alternatives().try(Joi.object(), Joi.func()).default({ plugins: [] }), 49 | reshape: Joi.alternatives().try(Joi.object(), Joi.array(), Joi.func()).default({ locals: {} }), 50 | babel: Joi.object(), 51 | cleanUrls: Joi.bool().default(true), 52 | dumpDirs: Joi.array().default(['views', 'assets']), 53 | ignore: Joi.array().default([]), 54 | entry: Joi.object().keys({ 55 | arg: Joi.string(), 56 | value: Joi.array().items(Joi.string()).single() 57 | }).default({ 'js/main': ['./assets/js/index.js'] }), 58 | vendor: Joi.array().single(), 59 | outputDir: Joi.string().default('public'), 60 | outputPublicPath: Joi.string(), 61 | plugins: Joi.array().default([]), 62 | afterSpikePlugins: Joi.array().default([]), 63 | module: Joi.object().default().keys({ 64 | rules: Joi.array().default([]) 65 | }), 66 | devServer: Joi.func(), 67 | server: Joi.object().default().keys({ 68 | watchOptions: Joi.object().default().keys({ 69 | ignored: Joi.array().default('node_modules') 70 | }), 71 | server: Joi.default({}) 72 | .when('proxy', { is: Joi.exist(), 73 | then: Joi.required().only(false), 74 | otherwise: Joi.object() 75 | }), 76 | proxy: Joi.alternatives().try( 77 | Joi.object().keys({ target: Joi.string().required() }), 78 | Joi.string() 79 | ).optional(), 80 | port: Joi.number().default(1111), 81 | middleware: Joi.array().default([]), 82 | logLevel: Joi.string().default('silent'), 83 | logPrefix: Joi.string().default('spike'), 84 | notify: Joi.bool().default(false), 85 | host: Joi.string().default('localhost') 86 | }) 87 | }) 88 | 89 | const validation = Joi.validate(opts, schema, { allowUnknown: true }) 90 | if (validation.error) { throw new Error(validation.error) } 91 | let res = validation.value 92 | 93 | // Joi can't handle defaulting this, so we do it manually 94 | if (res.server.server) { 95 | res.server.server.baseDir = res.outputDir.replace(res.root, '') 96 | } 97 | 98 | // add cleanUrls middleware to browserSync if cleanUrls === true 99 | if (res.cleanUrls) { 100 | res.server.middleware.unshift(hygienist(res.server.server.baseDir)) 101 | } 102 | 103 | // webpack must consume an array for the value in our entry object 104 | for (let key in res.entry) { res.entry[key] = Array.prototype.concat(res.entry[key]) } 105 | 106 | // ensure server.watchOptions.ignored is an array (browsersync accepts 107 | // string or array), then push ['node_modules', '.git', outputDir] to make 108 | // sure they're not watched 109 | res.server.watchOptions.ignored = Array.prototype.concat(res.server.watchOptions.ignored) 110 | res.server.watchOptions.ignored = union(res.server.watchOptions.ignored, [ 111 | 'node_modules', 112 | '.git', 113 | res.outputDir 114 | ]) 115 | 116 | // core ignores 117 | res.ignore.unshift( 118 | 'node_modules/**', // node_modules folder 119 | `${res.outputDir}/**`, // anything in the public folder 120 | '.git/**', // any git content 121 | 'package.json', // the primary package.json file 122 | '**/.DS_Store', // any dumb DS Store file 123 | 'app.js', // primary config 124 | 'app.*.js' // any environment config 125 | ) 126 | 127 | // Loader excludes use absolute paths for some reason, so we add the context 128 | // to the beginning of the path so users can input them as relative to root. 129 | res.ignore = res.ignore.map((i) => path.join(res.root, i)) 130 | 131 | // Here we set up the matchers that will watch for newly added files to the 132 | // project. 133 | // 134 | // The browsersync matcher doesn't like absolute paths, so we calculate the 135 | // relative path from cwd to your project root. Usually this will be an 136 | // empty string as spike commands are typically run from the project root. 137 | // 138 | // We then add all the watcher ignores so that they do not trigger the "new 139 | // file added to the project" code path. They are added twice, the first 140 | // time for the directory contents, and the second for the directory itself. 141 | const p = path.relative(process.cwd(), res.root) 142 | let allWatchedFiles = [path.join(p, '**/*')] 143 | .concat(res.server.watchOptions.ignored.map((i) => { 144 | return `!${path.join(p, i, '**/*')}` 145 | })) 146 | .concat(res.server.watchOptions.ignored.map((i) => { 147 | return `!${path.join(p, i)}` 148 | })) 149 | 150 | // catch newly added files, put through the pipeline 151 | res.server.files = [{ 152 | match: allWatchedFiles, 153 | fn: (event, file) => { 154 | const util = new SpikeUtils(this) 155 | const f = path.join(this.context, file.replace(p, '')) 156 | const opts = util.getSpikeOptions() 157 | if (opts.files.all.indexOf(f) < 0 && !util.isFileIgnored(f) && event !== 'addDir') { 158 | opts.project.watcher.watch([], [], [f]) 159 | } 160 | } 161 | }] 162 | 163 | return res 164 | } 165 | 166 | /** 167 | * Takes a valid spike options object and transforms it into valid webpack 168 | * configuration, applied directly as properties of the class. 169 | * @param {Object} opts - validated spike options object 170 | * @return {Class} returns self, but with the properties of a webpack config 171 | */ 172 | transformSpikeOptionsToWebpack (opts) { 173 | // `disallow` options would break spike if modified. 174 | const disallow = ['output', 'resolveLoader', 'spike', 'plugins', 'afterSpikePlugins', 'context', 'outputPublicPath'] 175 | 176 | // `noCopy` options are spike-specific and shouldn't be directly added to 177 | // webpack's config 178 | const noCopy = ['root', 'matchers', 'env', 'server', 'cleanUrls', 'dumpDirs', 'ignore', 'vendor', 'outputDir', 'css', 'postcss', 'reshape', 'babel'] 179 | 180 | // All options other than `disallow` or `noCopy` are added directly to 181 | // webpack's config object 182 | const filteredOpts = removeKeys(opts, disallow.concat(noCopy)) 183 | Object.assign(this, filteredOpts) 184 | 185 | // `noCopy` options are added under the `spike` property 186 | const spike = { files: {} } 187 | Object.assign(spike, filterKeys(opts, noCopy)) 188 | 189 | // Now we run some spike-specific config transforms 190 | this.context = opts.root 191 | 192 | this.output = { 193 | path: path.join(this.context, opts.outputDir), 194 | filename: '[name].js' 195 | } 196 | 197 | // this is sometimes necessary for webpackjsonp loads in old browsers 198 | if (opts.outputPublicPath) { 199 | this.output.publicPath = opts.outputPublicPath 200 | } 201 | 202 | this.resolveLoader = { 203 | modules: [ 204 | path.join(opts.root), // the project root 205 | path.join(opts.root, 'node_modules'), // the project node_modules 206 | path.join(__dirname, '../node_modules'), // spike/node_modules 207 | path.join(__dirname, '../../../node_modules') // spike's flattened deps, via npm 3+ 208 | ] 209 | } 210 | 211 | // If the user has passed options, accept them, but the ones set above take 212 | // priority if there's a conflict, as they are essential to spike. 213 | if (opts.resolveLoader) { 214 | this.resolveLoader = merge(opts.resolveLoader, this.resolveLoader) 215 | } 216 | 217 | const reIgnores = opts.ignore.map(mmToRe) 218 | const spikeLoaders = [ 219 | { 220 | exclude: reIgnores, 221 | test: '/core!css', 222 | use: [ 223 | { loader: 'source-loader' }, 224 | { loader: 'postcss-loader', options: opts.postcss } 225 | ] 226 | }, { 227 | exclude: reIgnores, 228 | test: '/core!js', 229 | use: [ 230 | { loader: 'babel-loader', options: opts.babel } 231 | ] 232 | }, { 233 | exclude: reIgnores, 234 | test: '/core!html', 235 | use: [ 236 | { loader: 'source-loader' }, 237 | { loader: 'reshape-loader', options: opts.reshape } 238 | ] 239 | }, { 240 | exclude: reIgnores, 241 | test: '/core!static', 242 | use: [ 243 | { loader: 'source-loader' } 244 | ] 245 | } 246 | ] 247 | 248 | this.module.rules = spikeLoaders.concat(opts.module.rules) 249 | 250 | const util = new SpikeUtils(this) 251 | const spikePlugin = new SpikePlugin(util, spike) 252 | 253 | this.plugins = [ 254 | ...opts.plugins, 255 | spikePlugin, 256 | ...opts.afterSpikePlugins, 257 | new BrowserSyncPlugin(opts.server, { callback: (_, bs) => { 258 | if (bs.utils.devIp.length) { 259 | spike.project.emit('info', `External IP: http://${bs.utils.devIp[0]}:${spike.server.port}`) 260 | } 261 | } }) 262 | ] 263 | 264 | return this 265 | } 266 | 267 | /** 268 | * Looks for an "app.js" file at the project root, if there is one parses its 269 | * contents into spike options and validates them. If there is an environment 270 | * provided as an option, also pulls the environment config and merges it in. 271 | * @param {String} root - path to the root of a spike project 272 | * @return {Object} validated spike options object 273 | */ 274 | parseAppJs (opts) { 275 | if (opts.env) { process.env.SPIKE_ENV = opts.env } 276 | let config = loadFile(path.resolve(opts.root, 'app.js')) 277 | 278 | if (opts.env) { 279 | const envConfig = loadFile(path.resolve(opts.root, `app.${opts.env}.js`)) 280 | config = merge(config, envConfig) 281 | } 282 | 283 | return config 284 | } 285 | } 286 | 287 | // utils 288 | 289 | function loadFile (f) { 290 | try { 291 | accessSync(f) 292 | return require(f) 293 | } catch (err) { 294 | if (err.code !== 'ENOENT') { throw new Error(err) } 295 | return {} 296 | } 297 | } 298 | 299 | function mmToRe (mm) { 300 | return micromatch.makeRe(mm) 301 | } 302 | 303 | function removeKeys (obj, keys) { 304 | const res = {} 305 | for (const k in obj) { if (keys.indexOf(k) < 0) res[k] = obj[k] } 306 | return res 307 | } 308 | 309 | function filterKeys (obj, keys) { 310 | const res = {} 311 | for (const k in obj) { if (keys.indexOf(k) > 0) res[k] = obj[k] } 312 | return res 313 | } 314 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Errors 3 | */ 4 | 5 | /** 6 | * @class SpikeError 7 | * @classdesc A spike-specific error class, echoes the underlying error but 8 | * with an added id, if provided. 9 | * @param {Object} args - error arguments object 10 | * @param {Number} args.id - error id 11 | * @param {String} args.message - the error message 12 | */ 13 | class SpikeError extends Error { 14 | constructor (args) { 15 | super(args.err) 16 | this.id = args.id 17 | this.message = args.err.message 18 | this.stack = args.err.stack 19 | if (!this.message) this.raw = args.err 20 | } 21 | } 22 | exports.Error = SpikeError 23 | 24 | /** 25 | * @class SpikeWarning 26 | * @classdesc A spike-specific warning class 27 | * @param {Object} args - error arguments object 28 | * @param {Number} args.id - error id 29 | * @param {String} args.message - the error message 30 | */ 31 | class SpikeWarning extends SpikeError {} 32 | exports.Warning = SpikeWarning 33 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Spike 3 | */ 4 | 5 | const path = require('path') 6 | const fs = require('fs') 7 | const os = require('os') 8 | const mkdirp = require('mkdirp') 9 | const W = require('when') 10 | const node = require('when/node') 11 | const {exec} = require('child_process') 12 | const webpack = require('webpack') 13 | const rimraf = require('rimraf') 14 | const Sprout = require('sprout') 15 | const Joi = require('joi') 16 | const {EventEmitter} = require('events') 17 | const Config = require('./config') 18 | const {Error, Warning} = require('./errors') 19 | 20 | /** 21 | * @class Spike 22 | * @classdesc Creates a spike project instance and allows interaction with it 23 | * @param {Object} opts - documented in the readme 24 | */ 25 | 26 | class Spike extends EventEmitter { 27 | constructor (opts = {}) { 28 | super() 29 | this.config = new Config(opts, this) 30 | } 31 | 32 | /** 33 | * Compiles the spike project once 34 | * @fires Spike#error 35 | * @fires Spike#warning 36 | * @fires Spike#compile 37 | * @return {Object} compile id, webpack compiler 38 | */ 39 | compile () { 40 | const id = uuid() 41 | const compiler = webpack(this.config) 42 | compiler.run(compileCallback.bind(this, id)) 43 | return {id, compiler} 44 | } 45 | 46 | /** 47 | * Compiles a project and watches it, when a file changes, recompiles 48 | * @param {Object} opts - options to be passed to webpack.watch 49 | * @fires Spike#error 50 | * @fires Spike#warning 51 | * @fires Spike#compile 52 | * @return {Object} watch id, webpack watcher 53 | */ 54 | watch (opts = {}) { 55 | const id = uuid() 56 | this.compiler = webpack(this.config) 57 | const watcher = this.compiler.watch(opts, compileCallback.bind(this, id)) 58 | this.watcher = watcher 59 | return {id, watcher} 60 | } 61 | 62 | /** 63 | * Removes the public directory 64 | * @fires Spike#remove 65 | */ 66 | clean () { 67 | rimraf(this.config.output.path, () => { 68 | this.emit('remove', 'cleaned output directory') 69 | }) 70 | } 71 | 72 | /** 73 | * Creates a new spike project from a template and returns the instance 74 | * @static 75 | * @param {Object} options - options for new project 76 | * @param {String} options.root - path ro the root of the project 77 | * @param {String} [options.template] - name of the template to use 78 | * @param {Object} [options.locals] - locals provided to the sprout template 79 | * @param {EventEmitter} [options.emitter] - an event emitter for feedback 80 | * @param {Inquirer} [options.inquirer] - inquirer instance for CLI 81 | * @fires Spike#info 82 | * @fires Spike#error 83 | * @fires Spike#done 84 | */ 85 | static new (options) { 86 | // validate options 87 | const schema = Joi.object().keys({ 88 | root: Joi.string().required(), 89 | template: Joi.string(), 90 | locals: Joi.object(), 91 | emitter: Joi.func(), 92 | inquirer: Joi.func() 93 | }) 94 | const opts = Joi.validate(options, schema).value 95 | const emit = opts.emitter.emit.bind(opts.emitter) 96 | const sprout = initSprout(emit) 97 | 98 | // If a template option was passed, grab the source from sprout if possible 99 | // If not, use the default template options 100 | if (opts.template) { 101 | const tpl = sprout.templates[opts.template] 102 | if (tpl) { 103 | opts.src = tpl.src 104 | } else { 105 | return emit('error', `template "${opts.template}" has not been added to spike`) 106 | } 107 | } else { 108 | const conf = readConfig().defaultTemplate 109 | opts.template = conf.name 110 | opts.src = conf.src 111 | } 112 | 113 | // if the template doesn't exist, add it from the source 114 | let promise = W.resolve() 115 | if (!sprout.templates[opts.template]) { 116 | emit('info', `adding template "${opts.template}"`) 117 | promise = sprout.add(opts.template, opts.src) 118 | } 119 | 120 | // set up sprout options and iniquirer for prompts 121 | const sproutOptions = { 122 | locals: opts.locals, 123 | questionnaire: opts.inquirer 124 | } 125 | 126 | // run it 127 | promise 128 | .tap(() => emit('info', 'initializing template')) 129 | .then(sprout.init.bind(sprout, opts.template, opts.root, sproutOptions)) 130 | .tap(() => emit('info', 'installing production dependencies')) 131 | .then(npmInstall.bind(null, opts)) 132 | .then(() => new Spike({ root: opts.root })) 133 | .done( 134 | (instance) => { emit('done', instance) }, 135 | (err) => { emit('error', err) } 136 | ) 137 | } 138 | } 139 | 140 | /** 141 | * Generates a unique id for each compile run 142 | * @private 143 | */ 144 | function uuid () { 145 | return (Math.random().toString(16) + '000000000').substr(2, 8) 146 | } 147 | 148 | /** 149 | * Runs `npm install` from the command line in the project root 150 | * @private 151 | * @return {Promise.} promise for the command output 152 | */ 153 | function npmInstall (opts) { 154 | const pkgPath = path.join(opts.root, 'package.json') 155 | if (!fs.existsSync(pkgPath)) { return } 156 | 157 | return node.call(exec, 'npm install --production', { cwd: opts.root }) 158 | } 159 | 160 | /** 161 | * Function to be executed after a compile finishes. Handles errors and emits 162 | * events as necessary. 163 | * @param {Number} id - the compile's id 164 | * @param {Error} [err] - if there was an error, it's here 165 | * @param {WebpackStats} stats - stats object from webpack 166 | * @fires Spike#error 167 | * @fires Spike#warning 168 | * @fires Spike#compile 169 | */ 170 | function compileCallback (id, err, stats) { 171 | if (err) { 172 | return this.emit('error', new Error({ id, err })) 173 | } 174 | // Webpack "soft errors" are classified as warnings in spike. An error is 175 | // an error. If it doesn't break the build, it's a warning. 176 | const cstats = stats.compilation 177 | if (cstats.errors.length) { 178 | this.emit('warning', new Warning({ id, err: cstats.errors[0] })) 179 | } 180 | /* istanbul ignore next */ 181 | if (cstats.warnings.length) { 182 | this.emit('warning', new Warning({ id, err: cstats.warnings[0] })) 183 | } 184 | 185 | this.emit('compile', {id, stats}) 186 | } 187 | 188 | /** 189 | * A set of functions for controlling new project templates 190 | */ 191 | Spike.template = { 192 | /** 193 | * Adds a template to spike's stash 194 | * @param {Object} options 195 | * @param {String} name - name of the template to add 196 | * @param {String} src - url from which the template can be `git clone`d 197 | * @param {EventEmitter} emitter - will return events to report progress 198 | * @fires emitter#info 199 | * @fires emitter#success 200 | * @fires emitter#error 201 | */ 202 | add: function (options = {}) { 203 | const schema = Joi.object().keys({ 204 | name: Joi.string().required(), 205 | src: Joi.string().required(), 206 | emitter: Joi.func().required() 207 | }) 208 | const {opts, emit, sprout} = this._init(options, schema) 209 | 210 | emit('info', 'adding template') 211 | sprout.add(opts.name, opts.src).done(() => { 212 | emit('success', `template "${opts.name}" added`) 213 | }, (err) => { 214 | emit('error', err) 215 | }) 216 | }, 217 | /** 218 | * Removes a template from spike's list 219 | * @param {Object} options 220 | * @param {String} name - name of the template to remove 221 | * @param {EventEmitter} emitter - will return events to report progress 222 | * @fires emitter#info 223 | * @fires emitter#success 224 | */ 225 | remove: function (options = {}) { 226 | const schema = Joi.object().keys({ 227 | name: Joi.string().required(), 228 | emitter: Joi.func().required() 229 | }) 230 | const {opts, emit, sprout} = this._init(options, schema) 231 | 232 | emit('info', 'removing template') 233 | sprout.remove(opts.name).done(() => { 234 | emit('success', `template "${opts.name}" removed`) 235 | }) 236 | }, 237 | /** 238 | * Sets a template as the default when creating projects with `Spike.new` 239 | * @param {Object} options 240 | * @param {String} name - name of the template to make default 241 | * @param {EventEmitter} [emitter] - will return events to report progress 242 | * @fires emitter#info 243 | * @fires emitter#success 244 | * @fires emitter#error 245 | */ 246 | default: function (options = {}) { 247 | const schema = Joi.object().keys({ 248 | name: Joi.string().required(), 249 | emitter: Joi.func().default({ emit: (x) => x }) 250 | }) 251 | const {opts, emit, sprout} = this._init(options, schema) 252 | const tpl = sprout.templates[opts.name] 253 | 254 | if (tpl) { 255 | writeConfig({ defaultTemplate: { name: tpl.name, src: tpl.src } }) 256 | const message = `template "${opts.name}" is now the default` 257 | emit('success', message) 258 | return message 259 | } else { 260 | const message = `template "${opts.name}" doesn't exist` 261 | emit('error', message) 262 | return message 263 | } 264 | }, 265 | /** 266 | * Sets a template as the default when creating projects with `Spike.new` 267 | * @param {EventEmitter} [emitter] - will return events to report progress 268 | * @fires emitter#info 269 | * @fires emitter#success 270 | */ 271 | list: function (options = {}) { 272 | const schema = Joi.object().keys({ 273 | emitter: Joi.func().default({ emit: (x) => x }) 274 | }) 275 | const {emit, sprout} = this._init(options, schema) 276 | 277 | emit('success', sprout.templates) 278 | return sprout.templates 279 | }, 280 | /** 281 | * Removes the primary spike config and all templates, like a clean install 282 | */ 283 | reset: function () { 284 | try { 285 | fs.unlinkSync(Spike.configPath) 286 | rimraf.sync(Spike.tplPath) 287 | } catch (_) { } 288 | }, 289 | /** 290 | * Internal utility function 291 | * @private 292 | */ 293 | _init: function (options, schema) { 294 | const opts = Joi.validate(options, schema).value 295 | const emit = opts.emitter.emit.bind(opts.emitter) 296 | const sprout = initSprout(emit) 297 | return {opts, emit, sprout} 298 | } 299 | } 300 | 301 | // path where spike stores defaults and new project templates 302 | Spike.configPath = path.join(os.homedir(), '.spike/config.json') 303 | Spike.tplPath = path.join(os.homedir(), '.spike/templates') 304 | Spike.globalConfig = readConfig 305 | 306 | module.exports = Spike 307 | 308 | /** 309 | * Ensures the folder structure is present and initializes sprout 310 | * @param {EventEmitter} - event emitter to report progress 311 | * @return {Sprout} initialized sprout instance 312 | */ 313 | function initSprout (emit) { 314 | try { 315 | fs.accessSync(Spike.tplPath) 316 | } catch (_) { 317 | emit('info', 'configuring template storage') 318 | mkdirp.sync(Spike.tplPath) 319 | } 320 | return new Sprout(Spike.tplPath) 321 | } 322 | 323 | /** 324 | * Reads spike's global configuration file 325 | * @return {Object} config values 326 | */ 327 | function readConfig () { 328 | ensureConfigExists() 329 | return JSON.parse(fs.readFileSync(Spike.configPath, 'utf8')) 330 | } 331 | 332 | /** 333 | * Writes an object to spike's global config 334 | * @param {Object} data - what you want to write to the file 335 | */ 336 | function writeConfig (data) { 337 | ensureConfigExists() 338 | fs.writeFileSync(Spike.configPath, JSON.stringify(data)) 339 | } 340 | 341 | /** 342 | * If spike's global config file doesn't exist, creates it with the default 343 | * template in place. 344 | */ 345 | function ensureConfigExists () { 346 | const defaultConfig = { 347 | id: uuid(), 348 | analytics: true, 349 | defaultTemplate: { 350 | name: 'base', 351 | src: 'https://github.com/static-dev/spike-tpl-base.git' 352 | } 353 | } 354 | 355 | try { 356 | fs.accessSync(Spike.tplPath) 357 | } catch (_) { 358 | mkdirp.sync(Spike.tplPath) 359 | } 360 | 361 | try { 362 | fs.accessSync(Spike.configPath) 363 | } catch (_) { 364 | fs.writeFileSync(Spike.configPath, JSON.stringify(defaultConfig)) 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /lib/plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module SpikePlugin 3 | */ 4 | 5 | const path = require('path') 6 | const glob = require('glob') 7 | const File = require('filewrap') 8 | const difference = require('lodash.difference') 9 | 10 | /** 11 | * @class SpikeWebpackPlugin 12 | * @classdesc A webpack plugin that reads files from a Spike project, adds them 13 | * to webpack's pipeline, lets them compile, then extracts and writes them with 14 | * the correct extension. 15 | */ 16 | module.exports = class SpikeWebpackPlugin { 17 | /** 18 | * @constructor 19 | * @param {Object} opts - options for configuration 20 | * @param {String} opts.root - project root 21 | * @param {Array} opts.dumpDirs - directories to dump to public 22 | */ 23 | constructor (util, opts) { 24 | this.name = 'spikePlugin' 25 | this.util = util 26 | this.options = opts 27 | } 28 | 29 | /** 30 | * Generic webpack plugin hook method 31 | * see https://webpack.github.io/docs/plugins.html 32 | * @param {Object} compiler - webpack compiler instance 33 | */ 34 | apply (compiler) { 35 | let processedFiles, filesOnly 36 | 37 | // pull files from the project root and categorize them 38 | this.util.runAll(compiler, this.sortFiles.bind(this, compiler)) 39 | 40 | // inject files into webpack's pipeline 41 | compiler.plugin('make', (compilation, done) => { 42 | // stores files to be processed by spike 43 | processedFiles = this.options.files.process 44 | filesOnly = processedFiles.map((f) => f.path) 45 | // see spike-util for details on this method 46 | this.util.addFilesAsWebpackEntries(compilation, filesOnly) 47 | .done(() => done(), done) 48 | }) 49 | 50 | // grab the sources and dependencies and export them into the right files 51 | // have webpack export them into their own files 52 | compiler.plugin('emit', (compilation, done) => { 53 | processedFiles.forEach((f) => { 54 | // find the compiled file in webpack's modules 55 | const dep = compilation.modules.find((el) => { 56 | if (el.userRequest === f.path) { return el } 57 | }) 58 | 59 | // if the dep isn't found, we have a bad loader issue 60 | // TODO: add a test for this 61 | if (!dep) { 62 | return new Error(`Module ${f.path} not found in webpack.\nMost likely, this is an issue with trying to add a custom loader. If you are trying to output your custom loader files as static, adding 'source-loader' should solve the issue. If you are trying to require them into your javascript, adding the _skipSpikeProcessing option to your loader config should solve the issue.`) 63 | } 64 | 65 | // if we have an error in the module, return that 66 | if (dep.error) return done(dep.error) 67 | 68 | // get the compiled source of the module 69 | // this will either come from the source-loader, which exports a clean 70 | // buffer as dep._src, or from webpack's raw source value 71 | const src = dep._src 72 | ? dep._src 73 | : JSON.parse(dep._source._value.replace(/^module\.exports = /, '')) 74 | 75 | // calculate the output path 76 | // this will use the special prop f.outPath as an override, which can 77 | // be set by plugins to create a custom output path 78 | let outputPath = this.util.getOutputPath(f.outPath || f.path).relative 79 | 80 | // set the file's extension if necessary 81 | if (f.extension) { 82 | outputPath = outputPath.replace(/(.*)?(\..+?$)/, `$1.${f.extension}`) 83 | } 84 | 85 | if (dep._outputMultiple) { 86 | // if we have an outputMultiple flag, that means more than one 87 | // output (shocking, i know) 88 | const sources = JSON.parse(String(src)) 89 | for (let k in sources) { 90 | let multiOutPath = k 91 | if (!k.match(/\.html$/)) multiOutPath += '.html' 92 | compilation.assets[multiOutPath] = { 93 | source: () => { return sources[k] }, 94 | size: () => { return sources[k].length } 95 | } 96 | } 97 | } else { 98 | // otherwise, send off the single file to webpack for emission 99 | compilation.assets[outputPath] = { 100 | source: () => { return src }, 101 | size: () => { return src.length } 102 | } 103 | } 104 | }) 105 | 106 | done() 107 | }) 108 | 109 | // remove assets from webpack pipeline since we already wrote them 110 | compiler.plugin('compilation', (compilation) => { 111 | compilation.plugin('optimize-chunk-assets', (chunks, done) => { 112 | this.util.removeAssets(compilation, filesOnly, chunks) 113 | done() 114 | }) 115 | }) 116 | } 117 | 118 | /** 119 | * Executed at the beginning of the compile process, this function reads the 120 | * spike project's file tree and sorts the files according to how they will 121 | * be processed. Called automatically through a webpack hook. 122 | * @param {Object} compiler - webpack's compiler 123 | * @param {Object} compilation - webpack's compilation 124 | * @param {Function} done - callback 125 | */ 126 | sortFiles (compiler, compilation, done) { 127 | // webpack is strict in its validation of loader props. we need to pass an 128 | // extra indentifier, so it is passed through the `test` prop, which we 129 | // re-assign here before it breaks anything! 130 | compiler.options.module.rules.map((rule) => { 131 | const match = typeof rule.test === 'string' && rule.test.match(/\/core!(\w+)/) 132 | if (match) { 133 | rule._core = match[1] 134 | delete rule.test 135 | } 136 | return rule 137 | }) 138 | 139 | // first we load up and categorize all the files in the project 140 | this.options.files = this.getFilesFromGlob() 141 | 142 | // now we set the core loaders' "test" properties to a very precise list of 143 | // files so that they only load the files we want 144 | compiler.options.module.rules.map((rule) => { 145 | if (!rule._core) { return } 146 | rule.test = pathToRegex(this.options.files[rule._core]) 147 | }) 148 | 149 | done() 150 | } 151 | 152 | /** 153 | * Given the config options, pulls all files from the fs that match any of the 154 | * provided matchers. 155 | * @return {Array} all matching file paths, absolute 156 | */ 157 | getFilesFromGlob () { 158 | let files = {} 159 | const util = this.util 160 | 161 | // First, we grab all the files in the project, other than the ignored 162 | // files of course. 163 | const matcher = `${util.conf.context.replace(/\\/g, '/')}/**/**` 164 | files.all = glob.sync(matcher, { ignore: this.options.ignore, dot: true, nodir: true }) 165 | if (path.sep === '\\') { 166 | files.all = files.all.map((f) => f.replace(/\//g, '\\')) 167 | } 168 | 169 | // There are three special types of files we want to *not* be processed by 170 | // spike's core plugins. Here, we find these three types of files and push 171 | // them into their own custom categories. 172 | 173 | // We start with a list of all files, which will be gradually cut down as 174 | // we find files to be ignored 175 | let allAvailableFiles = files.all 176 | 177 | // First step, we find any files that are already being processed by a 178 | // user-added loader that has the `skipSpikeProcessing` key (we will refer 179 | // to these as "skipLoaders"). 180 | const skipLoaderTests = util.conf.module.rules.reduce((m, l) => { 181 | const skipInOpts = l.use.find((u) => u.options && u.options._skipSpikeProcessing) 182 | if (!l._core && l.use && skipInOpts) { 183 | m.push(l.test) 184 | delete skipInOpts.options._skipSpikeProcessing 185 | } 186 | return m 187 | }, []) 188 | 189 | files.skipLoaders = allAvailableFiles.filter((f) => { 190 | return skipLoaderTests.find((t) => f.match(t) && f) 191 | }) 192 | 193 | allAvailableFiles = difference(allAvailableFiles, files.skipLoaders) 194 | 195 | // Then we grab any files in the `vendored` config 196 | files.vendor = matchRelative.call(this, allAvailableFiles, this.options.vendor) 197 | 198 | allAvailableFiles = difference(allAvailableFiles, files.vendor) 199 | 200 | // Finally, we pull any javascript files which are processed by webpack 201 | // internally using the 'js' matcher. 202 | files.js = matchRelative.call(this, allAvailableFiles, this.options.matchers.js) 203 | 204 | allAvailableFiles = difference(allAvailableFiles, files.js) 205 | 206 | // Next, we work through files that will be written out by our core 207 | // "static" plugin, which basically injects the file into webpack's 208 | // pipeline, lets it compile, then extracts and writes it out, modifying 209 | // the extension if necessary. 210 | // 211 | // This includes html and css files matched by the core matchers as well as 212 | // any files matched by custom loaders with an 'extension' property. 213 | 214 | files.process = [] 215 | files.html = [] 216 | files.css = [] 217 | files.static = [] 218 | 219 | // We start with the core matchers 220 | for (const key in this.options.matchers) { 221 | if (key === 'js') continue // js files are handled by webpack 222 | const matcher = this.options.matchers[key] 223 | const matchedFiles = matchRelative.call(this, allAvailableFiles, matcher) 224 | 225 | // add to the matcher's category so it's handled by the correct loader 226 | files[key].push(...matchedFiles) 227 | 228 | // then we add to the static category for the plugin 229 | const withExtensions = matchedFiles.map((f) => { return { path: f, extension: key } }) 230 | files.process.push(...withExtensions) 231 | } 232 | 233 | // Then we add custom loaders with an 'extension' property 234 | util.conf.module.rules.reduce((m, r) => { 235 | const hasExt = r.use.find((u) => u.options && u.options._spikeExtension) 236 | if (!hasExt) return m 237 | const ext = hasExt.options._spikeExtension 238 | delete hasExt.options._spikeExtension 239 | m.push([r, ext]) 240 | return m 241 | }, []).map(([r, ext]) => { 242 | const extensionLoaderMatches = allAvailableFiles.reduce((m, f) => { 243 | if (f.match(r.test)) m.push({ path: f, extension: ext }) 244 | return m 245 | }, []) 246 | files.process.push(...extensionLoaderMatches) 247 | }) 248 | 249 | allAvailableFiles = difference(allAvailableFiles, files.process.map((f) => f.path)) 250 | 251 | // Now with any files that are left over, we add them in without an 252 | // extension property. This means that they will be processed by spike as 253 | // static files, but their extensions wont be changed. 254 | const noExtension = allAvailableFiles.map((f) => { return { path: f } }) 255 | files.process.push(...noExtension) 256 | 257 | // Also add vendor files, as they are processed by spike's pipeline 258 | files.process.push(...files.vendor.map((f) => { return { path: f } })) 259 | 260 | // finally, add static/vendor to the "static" category so that our static 261 | // loader will process these files 262 | files.static.push(...allAvailableFiles, ...files.vendor) 263 | 264 | return files 265 | } 266 | } 267 | 268 | /** 269 | * Given an array of paths, convert to a regex that matches for presence of 270 | * any of the given paths in a single path. 271 | * @param {Array} paths - array of absolute paths 272 | * @return {RegExp} regex that matches presence of any of the input paths 273 | */ 274 | function pathToRegex (paths) { 275 | if (!paths.length) { return new RegExp('^.^') } 276 | return new RegExp(paths.map((p, i) => { 277 | if (path.sep === '/') { 278 | return p.replace(/\//g, '\\/') 279 | } else { 280 | return p.replace(/\\/g, '\\\\') 281 | } 282 | }).join('|')) 283 | } 284 | 285 | /** 286 | * Given a list of files and one or more glob matchers, returns a list of 287 | * matching files. Matchers are resolved relative to the project root rather 288 | * than their absolute paths. 289 | * @param {Array} files - array of absolute paths to files 290 | * @param {Array|String} matchers - one or more globstar matchers 291 | * @return {Array} - all files matched 292 | */ 293 | function matchRelative (files, matchers) { 294 | if (!matchers) return [] 295 | return files.filter((f) => { 296 | const file = new File(this.util.conf.context, f) 297 | return this.util.matchGlobs(file.relative, matchers, { dot: true })[0] 298 | }) 299 | } 300 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spike-core", 3 | "description": "an opinionated static build tool, powered by webpack", 4 | "version": "2.3.0", 5 | "author": "Jeff Escalante", 6 | "ava": { 7 | "verbose": "true", 8 | "serial": "true" 9 | }, 10 | "bugs": "https://github.com/static-dev/spike-core/issues", 11 | "dependencies": { 12 | "babel-core": "^6.26.0", 13 | "babel-loader": "^7.1.5", 14 | "browser-sync": "^2.24.6", 15 | "browser-sync-webpack-plugin": "^1.2.0", 16 | "filewrap": "^1.0.0", 17 | "glob": "^7.1.2", 18 | "hygienist-middleware": "^0.1.3", 19 | "joi": "^12.0.0", 20 | "lodash.difference": "^4.5.0", 21 | "lodash.merge": "^4.6.0", 22 | "lodash.union": "^4.6.0", 23 | "micromatch": "^3.1.4", 24 | "mkdirp": "^0.5.1", 25 | "postcss-loader": "^2.1.6", 26 | "reshape-loader": "^1.3.0", 27 | "rimraf": "^2.6.2", 28 | "source-loader": "^1.0.0", 29 | "spike-util": "^1.3.0", 30 | "sprout": "^1.2.1", 31 | "webpack": "^3.12.0", 32 | "when": "^3.7.8" 33 | }, 34 | "devDependencies": { 35 | "ava": "^0.25.0", 36 | "chalk": "^2.3.0", 37 | "coveralls": "^3.0.2", 38 | "husky": "^0.14.3", 39 | "md5-file": "^3.2.3", 40 | "nyc": "^12.0.2", 41 | "postcss-color-gray": "^4.0.0", 42 | "postcss-import": "^11.0.0", 43 | "reshape-custom-elements": "^0.1.0", 44 | "reshape-expressions": "^0.1.5", 45 | "snazzy": "^7.0.0", 46 | "standard": "^11.0.1", 47 | "sugarml": "^0.7.0", 48 | "sugarss": "^1.0.1" 49 | }, 50 | "engines": { 51 | "node": ">=6.0.0", 52 | "npm": ">=3.8.0" 53 | }, 54 | "homepage": "https://github.com/static-dev/spike-core", 55 | "keywords": [ 56 | "spike", 57 | "static", 58 | "webpack" 59 | ], 60 | "license": "MIT", 61 | "main": "lib", 62 | "repository": "static-dev/spike", 63 | "scripts": { 64 | "coverage": "nyc --reporter=html ava && open coverage/index.html", 65 | "coveralls": "nyc --reporter=lcov ava && cat ./coverage/lcov.info | coveralls", 66 | "lint": "standard --verbose | snazzy", 67 | "precommit": "npm run lint -s", 68 | "pretest": "npm run lint -s", 69 | "test": "ava " 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Spike Core 2 | 3 | [![version](https://img.shields.io/npm/v/spike-core.svg?style=flat)](https://www.npmjs.com/package/spike-core) [![tests](http://img.shields.io/travis/static-dev/spike-core/master.svg?style=flat)](https://travis-ci.org/static-dev/spike-core) [![windows](https://img.shields.io/appveyor/ci/jescalan/spike-core.svg)](https://ci.appveyor.com/project/jescalan/spike-core) [![dependencies](http://img.shields.io/david/static-dev/spike-core.svg?style=flat)](https://david-dm.org/static-dev/spike-core) [![coverage](https://img.shields.io/coveralls/static-dev/spike-core.svg?style=flat)](https://coveralls.io/github/static-dev/spike-core?branch=master) [![chat](https://img.shields.io/gitter/room/static-dev/spike.svg)](http://gitter.im/static-dev/spike) 4 | 5 | An opinionated static build tool, powered by [webpack](http://webpack.github.io) 6 | 7 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 8 | 9 | > Note: This project is currently unmaintained. If you are interested in taking over maintenance, please reach out! 10 | 11 | ## Why should you care? 12 | 13 | [We](https://github.com/carrot) [:heart:](http://giphy.com/gifs/steve-carell-cute-the-office-Yb8ebQV8Ua2Y0/tile) [static](https://www.smashingmagazine.com/2015/11/modern-static-website-generators-next-big-thing/). 14 | 15 | If you're building a website or client-side app – then :cactus: spike is probably for you. Spike aims to be simple, efficient, and a pleasure to use. 16 | 17 | Spike certainly is not the only [static site generator](https://www.staticgen.com/) out there, but in our opinion, it's the most powerful and easiest to use. 18 | 19 | > Spike is from the same [team](https://github.com/carrot) that brought you [Roots](http://roots.cx). The thinking behind moving past Roots is explained in [this article](https://medium.com/@jescalan/eaa10c75eb22). Please feel free to comment and contribute. 20 | 21 | ### The Stack 22 | 23 | Spike is fairly strict in enforcing a default stack. However, the stack allows for quite a large amount of flexibility as all of the parsers are simply foundations that do nothing by default and accept plugins to transform code. Also spike's core compiler is [Webpack](https://github.com/webpack/webpack), so you can customize your project with [loaders](https://webpack.github.io/docs/loaders.html) and [plugins](https://webpack.github.io/docs/plugins.html). The inflexibility of the stack means faster compiles and better stability. We use... 24 | 25 | - [reshape](https://github.com/reshape/reshape) for markup 26 | - [babel](https://babeljs.io/) for JS and JS transforms 27 | - [postcss](https://github.com/postcss/postcss) for CSS transforms 28 | - [webpack](http://webpack.github.io) as the core compiler 29 | 30 | ### Features 31 | 32 | - Easy configuration via the `app.js` file 33 | - Integration with [Webpack's](https://github.com/webpack/webpack) massive plugin/loader ecosystem 34 | - Support for ES6 in your client-side JS via Babel 35 | - PostCSS default means extensive flexibility in CSS syntax and tools 36 | - Reshape default means the same for your HTML 37 | - Breezy local development powered by [Browsersync](https://browsersync.io/) 38 | - Selective compile in `watch` mode :zap: 39 | - Support for [multiple environments](https://spike.readme.io/docs/environments) 40 | - Interactive Project Starters via [sprout](https://github.com/carrot/sprout) 41 | - [Spike Plugins](https://npms.io/search?q=spikeplugin) for common integrations 42 | 43 | ## Installation 44 | 45 | - `npm install spike-core -S` 46 | 47 | ## Usage 48 | 49 | Spike operates through a carefully crafted javascript interface. If you are looking to use spike through its command line interface, check out [spike](https://github.com/static-dev/spike). This project is just the core javascript API. 50 | 51 | [**Check out the documentation for the Javascript API here**](https://spike.readme.io/docs/javascript-api) 52 | -------------------------------------------------------------------------------- /test/_helpers.js: -------------------------------------------------------------------------------- 1 | const nodeFs = require('fs') 2 | const path = require('path') 3 | const When = require('when') 4 | const node = require('when/node') 5 | const chalk = require('chalk') 6 | const Spike = require('..') 7 | 8 | // export references to required modules and/or paths 9 | const fixturesPath = exports.fixturesPath = path.join(__dirname, 'fixtures') 10 | exports.fs = node.liftAll(nodeFs) 11 | 12 | /** 13 | * compiles a fixture into it's `public/` directory 14 | * @param {Object} t - ava test helper for setting t.context props 15 | * @param {String} name - the name of the fixture to compile 16 | * @return {Promise} - a promise for the compiled fixture and the path to 17 | * it's `public/` directory 18 | */ 19 | exports.compileFixture = function compileFixture (t, name, options = {}) { 20 | const testPath = path.join(fixturesPath, name) 21 | const project = new Spike(Object.assign(options, { root: testPath })) 22 | const publicPath = path.join(testPath, 'public') 23 | 24 | return When.promise((resolve, reject) => { 25 | project.on('error', reject) 26 | project.on('compile', (res) => { resolve({res, publicPath}) }) 27 | 28 | project.compile() 29 | }) 30 | } 31 | 32 | exports.debug = (msg) => console.log(chalk.gray(' ▸ ' + msg)) 33 | -------------------------------------------------------------------------------- /test/app_config.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const {compileFixture} = require('./_helpers') 3 | 4 | test('uses app.js configuration', (t) => { 5 | return compileFixture(t, 'app_config').then(({res}) => { 6 | t.truthy(res.stats.compilation.options.entry.foo[0] === 'override') 7 | }) 8 | }) 9 | 10 | test('API config overrides app.js config', (t) => { 11 | return compileFixture(t, 'app_config', { entry: { foo: 'double override' } }).then(({res}) => { 12 | t.truthy(res.stats.compilation.options.entry.foo[0] === 'double override') 13 | }) 14 | }) 15 | 16 | test('API config merges properly with app.js config', (t) => { 17 | return compileFixture(t, 'app_config', { entry: { bar: 'double override' } }).then(({res}) => { 18 | t.truthy(res.stats.compilation.options.entry.baz[0] === 'override') 19 | t.truthy(res.stats.compilation.options.entry.bar[0] === 'double override') 20 | }) 21 | }) 22 | 23 | test('throws error for invalid app.js syntax', (t) => { 24 | return t.throws(() => compileFixture(t, 'app_config_error'), /Error: wow/) 25 | }) 26 | 27 | test('does not allow certain options to be configured', (t) => { 28 | return compileFixture(t, 'app_config', { context: 'override!' }) 29 | .then(({res}) => { 30 | t.truthy(res.stats.compilation.options.context !== 'override!') 31 | }) 32 | }) 33 | 34 | test('passing options to loaders', (t) => { 35 | return compileFixture(t, 'app_config', { 36 | postcss: { plugins: ['wow'], foo: 'bar' } 37 | }).then(({res}) => { 38 | const opts = res.stats.compilation.options 39 | const cssLoader = opts.module.rules.find((r) => r._core === 'css') 40 | t.is(cssLoader.use[1].options.foo, 'bar') 41 | }) 42 | }) 43 | 44 | test('allows typeof string for entry object\'s value', (t) => { 45 | return compileFixture(t, 'app_config', { entry: { 'js/main': './js/index.js' } }) 46 | .then(({res}) => { 47 | t.truthy(Array.isArray(res.stats.compilation.options.entry['js/main'])) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/clean.js: -------------------------------------------------------------------------------- 1 | const Spike = require('..') 2 | const path = require('path') 3 | const test = require('ava') 4 | const {fs, compileFixture, fixturesPath} = require('./_helpers') 5 | 6 | test.before((t) => { 7 | return compileFixture(t, 'clean') 8 | .then(({publicPath}) => { return path.join(publicPath, 'index.html') }) 9 | .tap((index) => { return fs.stat(index).tap(t.truthy.bind(t)) }) 10 | .then((index) => { return fs.readFile(index, 'utf8') }) 11 | .then((contents) => { return t.is(contents.trim(), '

hello

') }) 12 | }) 13 | 14 | test.cb('emits clean message correctly', (t) => { 15 | const project = new Spike({ root: path.join(fixturesPath, 'clean') }) 16 | 17 | project.on('error', t.end) 18 | project.on('remove', (msg) => { 19 | t.truthy(msg.toString().match(/cleaned output directory/)) 20 | t.end() 21 | }) 22 | 23 | project.clean() 24 | }) 25 | -------------------------------------------------------------------------------- /test/compile.js: -------------------------------------------------------------------------------- 1 | const Spike = require('..') 2 | const test = require('ava') 3 | const path = require('path') 4 | const sugarml = require('sugarml') 5 | const {fixturesPath, compileFixture} = require('./_helpers') 6 | 7 | test('emits compile errors correctly', (t) => { 8 | return compileFixture(t, 'compile_error', { 9 | matchers: { html: '*(**/)*.sgr' }, 10 | reshape: { 11 | parser: sugarml, 12 | filename: (ctx) => ctx.resourcePath, 13 | locals: {} 14 | } 15 | }).catch((err) => { 16 | t.truthy(err.message.toString().match(/Cannot parse character "<"/)) 17 | }) 18 | }) 19 | 20 | test.cb('emits compile warnings correctly', (t) => { 21 | const project = new Spike({ root: path.join(fixturesPath, 'css') }) 22 | 23 | project.on('error', t.end) 24 | project.on('warning', (msg) => { 25 | t.truthy(msg.toString().match(/Error: Can't resolve '.\/assets\/js\/index\.js'/)) 26 | t.end() 27 | }) 28 | 29 | project.compile() 30 | }) 31 | 32 | test.skip('emits webpack warnings correctly', (t) => { 33 | console.log('need to be able to generate a webpack warning for this test') 34 | }) 35 | -------------------------------------------------------------------------------- /test/config_errors.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const Spike = require('..') 3 | 4 | test('config errors', (t) => { 5 | t.throws(() => { new Spike() }, // eslint-disable-line 6 | '[spike constructor] option "root" is required') 7 | t.throws(() => { new Spike({ root: 'foo', matchers: 'wow' }) }, // eslint-disable-line 8 | 'ValidationError: child "matchers" fails because ["matchers" must be an object]') 9 | t.throws(() => { new Spike({ root: 'foo', matchers: { css: [1] } }) }, // eslint-disable-line 10 | 'ValidationError: child "matchers" fails because [child "css" fails because ["css" must be a string]]') 11 | t.throws(() => { new Spike({ root: 'foo', postcss: 8 }) }, // eslint-disable-line 12 | 'ValidationError: child "postcss" fails because ["postcss" must be an object, "postcss" must be a Function]') 13 | t.throws(() => { new Spike({ root: 'foo', babel: 'wow' }) }, // eslint-disable-line 14 | 'ValidationError: child "babel" fails because ["babel" must be an object]') 15 | t.throws(() => { new Spike({ root: 'foo', entry: ['foo', 'bar'] }) }, // eslint-disable-line 16 | 'ValidationError: child "entry" fails because ["entry" must be an object]') 17 | t.throws(() => { new Spike({ root: 'foo', server: {server: false} }) }, // eslint-disable-line 18 | 'ValidationError: child "server" fails because [child "server" fails because ["server" must be an object]]') 19 | t.throws(() => { new Spike({ root: 'foo', server: {server: {}, proxy: 'http://localhost:1234/'} }) }, // eslint-disable-line 20 | 'ValidationError: child "server" fails because [child "server" fails because ["server" must be one of [false]]]') 21 | }) 22 | -------------------------------------------------------------------------------- /test/css.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const colorGray = require('postcss-color-gray') 4 | const {compileFixture, fs} = require('./_helpers') 5 | 6 | test('css files are compiled correctly', (t) => { 7 | return compileFixture(t, 'css') 8 | .then(({publicPath}) => { return path.join(publicPath, 'main.css') }) 9 | .tap((base) => { return fs.stat(base).tap(t.truthy.bind(t)) }) 10 | .then((base) => { return fs.readFile(base, 'utf8') }) 11 | .then((contents) => { return t.regex(contents, /color: pink/) }) 12 | }) 13 | 14 | test('css works with postcss plugins', (t) => { 15 | return compileFixture(t, 'css_plugin', { 16 | postcss: { plugins: [colorGray()] } 17 | }).then(({publicPath}) => { return path.join(publicPath, 'main.css') }) 18 | .tap((base) => { return fs.stat(base).tap(t.truthy.bind(t)) }) 19 | .then((base) => { return fs.readFile(base, 'utf8') }) 20 | .then((contents) => { return t.regex(contents, /color: rgb\(50, 50, 50\)/) }) 21 | }) 22 | 23 | test('css works with alternate parser', (t) => { 24 | return compileFixture(t, 'css_parser') 25 | .then(({publicPath}) => { return path.join(publicPath, 'main.css') }) 26 | .tap((index) => { return fs.stat(index).tap(t.truthy.bind(t)) }) 27 | .then((index) => { return fs.readFile(index, 'utf8') }) 28 | .then((contents) => { return t.regex(contents, /background: blue/) }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/dump_dirs.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const {compileFixture, fs} = require('./_helpers') 4 | 5 | test('discards directories, but keeps the directory\'s files', (t) => { 6 | return compileFixture(t, 'dump_dirs') 7 | .then(({publicPath}) => { return path.join(publicPath, 'index.html') }) 8 | .tap((index) => { return fs.stat(index).tap(t.truthy.bind(t)) }) 9 | .then((index) => { return fs.readFile(index, 'utf8') }) 10 | .then((contents) => { return t.is(contents.trim(), '

hello world!

') }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/environments.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const {compileFixture} = require('./_helpers') 3 | 4 | test('environment config parsed correctly', (t) => { 5 | return compileFixture(t, 'environments', { env: 'doge' }).then(({res}) => { 6 | t.is(res.stats.compilation.options.entry.doge1[0], 'doge') 7 | t.is(res.stats.compilation.options.entry.doge2[0], 'very') 8 | t.is(res.stats.compilation.options.entry.doge3[0], 'amaze') 9 | t.is(res.stats.compilation.options.entry.doge4[0], 'doge') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/fixtures/app_config/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { foo: 'override', bar: 'override', baz: 'override', outputPublicPath: 'test' } 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/app_config_error/app.js: -------------------------------------------------------------------------------- 1 | throw new Error('wow') 2 | -------------------------------------------------------------------------------- /test/fixtures/clean/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { open: false } 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/clean/index.html: -------------------------------------------------------------------------------- 1 |

hello

2 | -------------------------------------------------------------------------------- /test/fixtures/compile_error/index.sgr: -------------------------------------------------------------------------------- 1 | < 2 | -------------------------------------------------------------------------------- /test/fixtures/css/main.css: -------------------------------------------------------------------------------- 1 | p { color: pink; } 2 | -------------------------------------------------------------------------------- /test/fixtures/css_parser/_partial.sss: -------------------------------------------------------------------------------- 1 | body 2 | background: blue 3 | -------------------------------------------------------------------------------- /test/fixtures/css_parser/app.js: -------------------------------------------------------------------------------- 1 | const postcssImport = require('postcss-import') 2 | 3 | module.exports = { 4 | ignore: ['**/_*'], 5 | matchers: { css: '*(**/)*.sss' }, 6 | postcss: { 7 | parser: 'sugarss', // TODO: this shouldnt work like this 8 | plugins: [postcssImport()] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/css_parser/main.sss: -------------------------------------------------------------------------------- 1 | @import '_partial.sss' 2 | 3 | p 4 | color: red 5 | -------------------------------------------------------------------------------- /test/fixtures/css_plugin/main.css: -------------------------------------------------------------------------------- 1 | p { color: gray(50); } 2 | -------------------------------------------------------------------------------- /test/fixtures/dump_dirs/views/index.html: -------------------------------------------------------------------------------- 1 |

hello world!

2 | -------------------------------------------------------------------------------- /test/fixtures/environments/app.doge.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { doge3: 'amaze', doge4: process.env.SPIKE_ENV } 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/environments/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { doge2: 'very', doge1: process.env.SPIKE_ENV } 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/environments/index.sgr: -------------------------------------------------------------------------------- 1 | p hi! 2 | -------------------------------------------------------------------------------- /test/fixtures/es_modules/doge.js: -------------------------------------------------------------------------------- 1 | export default 'wow' 2 | -------------------------------------------------------------------------------- /test/fixtures/es_modules/index.js: -------------------------------------------------------------------------------- 1 | import doge from './doge' 2 | 3 | console.log(doge) 4 | -------------------------------------------------------------------------------- /test/fixtures/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | test 3 | 4 | 5 | 6 | hello there 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/html/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-core/15db8c60879e4e04de54bc6b7ea113a86261cc8c/test/fixtures/html/style.css -------------------------------------------------------------------------------- /test/fixtures/ignores/ignore.html: -------------------------------------------------------------------------------- 1 |

ignore me plz

2 | -------------------------------------------------------------------------------- /test/fixtures/ignores/index.html: -------------------------------------------------------------------------------- 1 |

hello world!

2 | -------------------------------------------------------------------------------- /test/fixtures/loader_custom_ext/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ['app.js', 'fooLoader.js'], 3 | resolveLoader: { alias: { fooLoader: './fooLoader.js' } }, 4 | module: { 5 | rules: [{ 6 | test: /\.foo$/, 7 | use: [{ 8 | loader: 'fooLoader', 9 | options: { _spikeExtension: 'txt' } 10 | }] 11 | }] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/loader_custom_ext/assets/js/foo.foo: -------------------------------------------------------------------------------- 1 | console.log('from file') 2 | -------------------------------------------------------------------------------- /test/fixtures/loader_custom_ext/assets/js/index.js: -------------------------------------------------------------------------------- 1 | require('./foo.foo') 2 | -------------------------------------------------------------------------------- /test/fixtures/loader_custom_ext/fooLoader.js: -------------------------------------------------------------------------------- 1 | module.exports = function (source, map) { 2 | return JSON.stringify('overwritten from local loader') 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/loader_custom_ext/index.html: -------------------------------------------------------------------------------- 1 |

foo

2 | -------------------------------------------------------------------------------- /test/fixtures/loader_source_error/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ['app.js'], 3 | module: { 4 | rules: [{ 5 | test: /\.scss$/, 6 | use: [{ 7 | loader: 'postcss', 8 | options: { _spikeExtension: 'css' } 9 | }] 10 | }] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/loader_source_error/main.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-core/15db8c60879e4e04de54bc6b7ea113a86261cc8c/test/fixtures/loader_source_error/main.js -------------------------------------------------------------------------------- /test/fixtures/loader_source_error/test.scss: -------------------------------------------------------------------------------- 1 | p { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/loaders/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ['app.js', 'fooLoader.js'], 3 | resolveLoader: { 4 | alias: { 5 | fooLoader: './fooLoader.js' 6 | } 7 | }, 8 | module: { 9 | rules: [{ test: /\.foo$/, use: [{ loader: 'fooLoader' }] }] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/loaders/assets/js/foo.foo: -------------------------------------------------------------------------------- 1 | from file 2 | -------------------------------------------------------------------------------- /test/fixtures/loaders/assets/js/index.js: -------------------------------------------------------------------------------- 1 | require('./foo.foo') 2 | -------------------------------------------------------------------------------- /test/fixtures/loaders/fooLoader.js: -------------------------------------------------------------------------------- 1 | module.exports = function (source, map) { 2 | return JSON.stringify('overwritten from local loader') 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/locals/index.jade: -------------------------------------------------------------------------------- 1 | = foo() 2 | -------------------------------------------------------------------------------- /test/fixtures/multi/index.html: -------------------------------------------------------------------------------- 1 |

{{ greeting }}

2 | -------------------------------------------------------------------------------- /test/fixtures/plugins/after_plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = class AfterSpikeWebpackPlugin { 2 | constructor (opts) { 3 | this.opts = opts 4 | } 5 | 6 | apply (compiler) { 7 | compiler.plugin('emit', (compilation, done) => { 8 | this.opts.emitter.emit('check', Object.keys(compilation.assets).includes('changed_output.html')) 9 | done() 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/plugins/app.js: -------------------------------------------------------------------------------- 1 | const TestPlugin = require('./plugin.js') 2 | 3 | module.exports = { 4 | entry: { test: 'foo' }, 5 | ignore: ['app.js', 'plugin.js', 'after_plugin.js'], 6 | plugins: [new TestPlugin()] 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/plugins/custom_output.html: -------------------------------------------------------------------------------- 1 |

my output path was rewritten by a plugin : (

2 | -------------------------------------------------------------------------------- /test/fixtures/plugins/index.html: -------------------------------------------------------------------------------- 1 |

hello there!

2 | -------------------------------------------------------------------------------- /test/fixtures/plugins/plugin.js: -------------------------------------------------------------------------------- 1 | const SpikeUtil = require('spike-util') 2 | 3 | module.exports = class TestWebpackPlugin { 4 | constructor (opts) { 5 | this.opts = opts 6 | } 7 | 8 | apply (compiler) { 9 | this.util = new SpikeUtil(compiler.options) 10 | compiler.plugin('run', (compilation, done) => { 11 | setTimeout(() => { 12 | compiler.options.entry.test = 'bar' 13 | done() 14 | }, 300) 15 | }) 16 | 17 | compiler.plugin('emit', (compilation, done) => { 18 | const file = this.util.getSpikeOptions().files.process.find((f) => { 19 | if (f.path.match(/custom_output/)) { return f } 20 | }) 21 | file.outPath = file.path.replace(/custom_output/, 'changed_output') 22 | done() 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/scope_hoisting/index.js: -------------------------------------------------------------------------------- 1 | import {something} from './util' 2 | 3 | something() 4 | -------------------------------------------------------------------------------- /test/fixtures/scope_hoisting/util.js: -------------------------------------------------------------------------------- 1 | export function something () { 2 | return 'wow' 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/skipSpikeProcessing/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ['app.js', 'fooLoader.js'], 3 | resolveLoader: { 4 | alias: { fooLoader: './fooLoader.js' } 5 | }, 6 | module: { 7 | rules: [{ 8 | test: /\.foo$/, 9 | use: [{ 10 | loader: 'fooLoader', 11 | options: { _skipSpikeProcessing: true } 12 | }] 13 | }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/fixtures/skipSpikeProcessing/assets/js/foo.foo: -------------------------------------------------------------------------------- 1 | from file 2 | -------------------------------------------------------------------------------- /test/fixtures/skipSpikeProcessing/assets/js/index.js: -------------------------------------------------------------------------------- 1 | require('./foo.foo') 2 | -------------------------------------------------------------------------------- /test/fixtures/skipSpikeProcessing/fooLoader.js: -------------------------------------------------------------------------------- 1 | module.exports = function (source, map) { 2 | return JSON.stringify('overwritten from local loader') 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/skipSpikeProcessing/index.html: -------------------------------------------------------------------------------- 1 |

foo

2 | -------------------------------------------------------------------------------- /test/fixtures/static/.empty.f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-core/15db8c60879e4e04de54bc6b7ea113a86261cc8c/test/fixtures/static/.empty.f -------------------------------------------------------------------------------- /test/fixtures/static/doge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-core/15db8c60879e4e04de54bc6b7ea113a86261cc8c/test/fixtures/static/doge.png -------------------------------------------------------------------------------- /test/fixtures/static/foo.wow: -------------------------------------------------------------------------------- 1 | hello there! 2 | -------------------------------------------------------------------------------- /test/fixtures/static/snargle/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/static_plugins/app.js: -------------------------------------------------------------------------------- 1 | const GladePlugin = require('./plugin.js') 2 | 3 | module.exports = { 4 | ignore: ['plugin.js'], 5 | plugins: [new GladePlugin()] 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/static_plugins/foo.glade: -------------------------------------------------------------------------------- 1 | glade is a really cool product 2 | -------------------------------------------------------------------------------- /test/fixtures/static_plugins/foo.html: -------------------------------------------------------------------------------- 1 |

foo

2 | -------------------------------------------------------------------------------- /test/fixtures/static_plugins/foo.spade: -------------------------------------------------------------------------------- 1 | p not processed by spike core 2 | -------------------------------------------------------------------------------- /test/fixtures/static_plugins/plugin.js: -------------------------------------------------------------------------------- 1 | const SpikeUtil = require('spike-util') 2 | 3 | module.exports = class GladePlugin { 4 | constructor (opts) { 5 | this.opts = opts 6 | } 7 | 8 | apply (compiler) { 9 | const util = new SpikeUtil(compiler.options) 10 | compiler.plugin('emit', function (compilation, done) { 11 | const staticFiles = util.getSpikeOptions().files.static 12 | const gladeFiles = staticFiles.filter((f) => f.match(/\.glade$/)) 13 | gladeFiles.forEach((f) => { 14 | const dep = compilation.modules.find((el) => { 15 | if (el.userRequest === f) { return el } 16 | }) 17 | let src = String(dep._src) 18 | src = src.replace(/glade/, 'Glade Air Freshener™') 19 | dep._src = Buffer.from(src) 20 | }) 21 | done() 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/sugarml/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-core/15db8c60879e4e04de54bc6b7ea113a86261cc8c/test/fixtures/sugarml/image.jpg -------------------------------------------------------------------------------- /test/fixtures/sugarml/index.sgr: -------------------------------------------------------------------------------- 1 | doctype html 2 | head 3 | title wowowowowowoowowowooooowowowowowwwwowoowowowow 4 | link(href='style.css') 5 | body 6 | img(src='image.jpg') 7 | p oh hello! 8 | script(src='script.js') 9 | -------------------------------------------------------------------------------- /test/fixtures/sugarml/script.js: -------------------------------------------------------------------------------- 1 | console.log('wow') 2 | -------------------------------------------------------------------------------- /test/fixtures/sugarml/style.css: -------------------------------------------------------------------------------- 1 | p 2 | color: red 3 | -------------------------------------------------------------------------------- /test/fixtures/vendor/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | vendor: 'keep/**' 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/vendor/index.html: -------------------------------------------------------------------------------- 1 |

index

2 | -------------------------------------------------------------------------------- /test/fixtures/vendor/keep/file.js: -------------------------------------------------------------------------------- 1 | console.log('vendored') 2 | -------------------------------------------------------------------------------- /test/fixtures/watch/index.html: -------------------------------------------------------------------------------- 1 |

watcher test

-------------------------------------------------------------------------------- /test/html.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const {compileFixture} = require('./_helpers') 5 | const customElements = require('reshape-custom-elements') 6 | 7 | test('compiles straight html using reshape', (t) => { 8 | return compileFixture(t, 'html', { reshape: { locals: {} } }) 9 | .then(({res, publicPath}) => { 10 | const src = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 11 | t.truthy(compress(src) === 'testhello there') 12 | }) 13 | }) 14 | 15 | test('can apply reshape plugins', (t) => { 16 | return compileFixture(t, 'html', { 17 | reshape: { plugins: [customElements()], locals: {} } 18 | }).then(({publicPath}) => { 19 | const src = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 20 | t.truthy(compress(src) === 'test
hello there
') 21 | }) 22 | }) 23 | 24 | function compress (html) { 25 | return html.replace(/>[\n|\s]*/g, '>') 26 | } 27 | -------------------------------------------------------------------------------- /test/ignores.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const {compileFixture, fs} = require('./_helpers') 4 | 5 | test('does not compile ignored files', (t) => { 6 | return compileFixture(t, 'ignores', { ignore: ['ignore.html'] }).tap(({publicPath}) => { 7 | return fs.access(path.join(publicPath, 'index.html')) 8 | }).tap(({publicPath}) => { 9 | return t.throws(fs.access(path.join(publicPath, 'ignore.html'))) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/js.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const {compileFixture} = require('./_helpers') 5 | 6 | test('works with es6 module imports', (t) => { 7 | return compileFixture(t, 'es_modules', { entry: { main: './index.js' } }) 8 | .then(({res, publicPath}) => { 9 | const src = fs.readFileSync(path.join(publicPath, 'main.js'), 'utf8') 10 | t.regex(src, /__WEBPACK_IMPORTED_MODULE_0__doge__/) 11 | t.regex(src, /'wow'/) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/loaders.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const rimraf = require('rimraf') 4 | const fs = require('fs') 5 | const {compileFixture, fixturesPath} = require('./_helpers') 6 | 7 | test.cb.beforeEach((t) => { 8 | rimraf(path.join(fixturesPath, 'loaders', 'public'), () => { t.end() }) 9 | }) 10 | 11 | test('compiles a project with a custom loader', (t) => { 12 | return compileFixture(t, 'loaders') 13 | .then(({publicPath}) => { 14 | const mainJs = path.join(publicPath, 'js/main.js') 15 | try { fs.accessSync(mainJs) } catch (e) { t.fail(e) } 16 | t.regex(fs.readFileSync(mainJs, 'utf8'), /overwritten from local loader/) 17 | 18 | const fooFile = path.join(publicPath, 'js/foo.foo') 19 | try { fs.accessSync(fooFile) } catch (e) { t.fail(e) } 20 | t.regex(fs.readFileSync(fooFile, 'utf8'), /overwritten from local loader/) 21 | }) 22 | }) 23 | 24 | test('custom loader and skipSpikeProcessing option', (t) => { 25 | return compileFixture(t, 'skipSpikeProcessing') 26 | .then(({publicPath}) => { 27 | const mainJs = path.join(publicPath, 'js/main.js') 28 | try { fs.accessSync(mainJs) } catch (e) { t.fail(e) } 29 | t.regex(fs.readFileSync(mainJs, 'utf8'), /overwritten from local loader/) 30 | 31 | const fooFile = path.join(publicPath, 'js/foo.foo') 32 | t.throws(() => fs.accessSync(fooFile)) 33 | }) 34 | }) 35 | 36 | test('custom loader with extension option', (t) => { 37 | return compileFixture(t, 'loader_custom_ext') 38 | .then(({publicPath}) => { 39 | const src = fs.readFileSync(path.join(publicPath, 'js/foo.txt'), 'utf8') 40 | t.is(src, 'overwritten from local loader') 41 | }) 42 | }) 43 | 44 | test('custom loader with incompatible return produces error', (t) => { 45 | return compileFixture(t, 'loader_source_error') 46 | .then(() => { t.fail('no error produced') }) 47 | .catch((err) => { 48 | t.truthy(err.message.toString().match(/Module build failed/)) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test/multi.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const exp = require('reshape-expressions') 5 | const {compileFixture} = require('./_helpers') 6 | 7 | test('compiles multi output templates correctly', (t) => { 8 | return compileFixture(t, 'multi', { 9 | reshape: { 10 | plugins: [exp()], 11 | multi: [ 12 | { locals: { greeting: 'hello' }, name: 'index.en' }, 13 | { locals: { greeting: 'hola' }, name: 'index.es.html' } 14 | ] 15 | } 16 | }).then(({res, publicPath}) => { 17 | const src1 = fs.readFileSync(path.join(publicPath, 'index.en.html'), 'utf8') 18 | const src2 = fs.readFileSync(path.join(publicPath, 'index.es.html'), 'utf8') 19 | t.is(src1.trim(), '

hello

') 20 | t.is(src2.trim(), '

hola

') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/new.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const Spike = require('..') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const rimraf = require('rimraf') 6 | const EventEmitter = require('events') 7 | const {fixturesPath, debug} = require('./_helpers') 8 | 9 | const testPath = path.join(fixturesPath, 'new_test') 10 | 11 | test.before(() => { Spike.template.reset() }) 12 | test.afterEach(() => { Spike.template.reset() }) 13 | 14 | test.cb('creates a new spike project', (t) => { 15 | const emitter = new EventEmitter() 16 | 17 | emitter.on('info', debug) // just because this test takes forever 18 | emitter.on('error', t.fail) 19 | emitter.on('done', (project) => { 20 | t.is(project.config.context, testPath) 21 | rimraf(testPath, t.end) 22 | }) 23 | 24 | Spike.new({ 25 | root: testPath, 26 | emitter: emitter, 27 | locals: { 28 | name: 'test', 29 | description: 'test', 30 | github_username: 'test', 31 | sugar: false, 32 | production: false 33 | } 34 | }) 35 | }) 36 | 37 | test.cb('creates a new project with a custom template', (t) => { 38 | const e1 = new EventEmitter() 39 | const e2 = new EventEmitter() 40 | 41 | e1.on('info', debug) 42 | e1.on('success', (res) => { 43 | Spike.new({ 44 | root: testPath, 45 | emitter: e2, 46 | template: 'test', 47 | locals: { 48 | name: 'test', 49 | description: 'test', 50 | github_username: 'test', 51 | foo: 'bar' 52 | } 53 | }) 54 | }) 55 | 56 | e2.on('info', debug) 57 | e2.on('done', () => { 58 | const contents = fs.readFileSync(path.join(testPath, 'index.html'), 'utf8') 59 | t.truthy(contents.trim() === '

basic template: bar

') 60 | rimraf(testPath, t.end) 61 | }) 62 | 63 | Spike.template.add({ 64 | name: 'test', 65 | src: 'https://github.com/jescalan/sprout-test-template', 66 | emitter: e1 67 | }) 68 | }) 69 | 70 | test.cb('errors if trying to create a project with nonexistant template', (t) => { 71 | const emitter = new EventEmitter() 72 | 73 | emitter.on('error', (msg) => { 74 | t.truthy(msg === 'template "doge" has not been added to spike') 75 | t.end() 76 | }) 77 | 78 | Spike.new({ 79 | root: testPath, 80 | emitter: emitter, 81 | template: 'doge' 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/plugins.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const EventEmitter = require('events') 4 | const webpack = require('webpack') 5 | const AfterPlugin = require('./fixtures/plugins/after_plugin') 6 | const {compileFixture, fs} = require('./_helpers') 7 | 8 | test('compiles a project with a custom plugin, plugins can change output path', (t) => { 9 | t.plan(2) 10 | const emitter = new EventEmitter() 11 | emitter.on('check', (val) => { t.is(val, true) }) 12 | return compileFixture(t, 'plugins', { 13 | afterSpikePlugins: [new AfterPlugin({ emitter })] 14 | }).then(({res, publicPath}) => { 15 | const index = path.join(publicPath, 'index.html') 16 | const outputChanged = path.join(publicPath, 'changed_output.html') 17 | fs.statSync(index) 18 | fs.statSync(outputChanged) 19 | t.truthy(res.stats.compilation.options.entry.test === 'bar') 20 | }) 21 | }) 22 | 23 | test('works with scope hoisting', (t) => { 24 | return compileFixture(t, 'scope_hoisting', { 25 | entry: { main: './index.js' }, 26 | plugins: [new webpack.optimize.ModuleConcatenationPlugin()] 27 | }).then(({res, publicPath}) => { 28 | return fs.readFile(path.join(publicPath, 'main.js'), 'utf8') 29 | .then((src) => { 30 | t.regex(src, /\/\/ CONCATENATED MODULE: \.\/util\.js/) 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/static.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const md5File = require('md5-file') 5 | const {compileFixture, fixturesPath} = require('./_helpers') 6 | 7 | test('static plugin copies over file with correct content', (t) => { 8 | return compileFixture(t, 'static').then(({publicPath}) => { 9 | const f1 = fs.readFileSync(path.join(publicPath, 'foo.wow'), 'utf8') 10 | const f2 = fs.readFileSync(path.join(publicPath, 'snargle/test.json'), 'utf8') 11 | const f3 = fs.readFileSync(path.join(publicPath, '.empty.f'), 'utf8') 12 | t.is(f1.trim(), 'hello there!', 'plain text not copied correctly') 13 | t.is(JSON.parse(f2).foo, 'bar', 'json not copied correctly') 14 | t.is(f3, '') 15 | 16 | const imgIn = md5File.sync(path.join(fixturesPath, 'static/doge.png')) 17 | const imgOut = md5File.sync(path.join(publicPath, 'doge.png')) 18 | t.is(imgIn, imgOut, 'image not copied correctly') 19 | }) 20 | }) 21 | 22 | test('static plugin ignores files processed by webpack plugins', (t) => { 23 | return compileFixture(t, 'static_plugins').then(({publicPath}) => { 24 | const f1 = fs.readFileSync(path.join(publicPath, 'foo.html'), 'utf8') 25 | const f2 = fs.readFileSync(path.join(publicPath, 'foo.spade'), 'utf8') 26 | const f3 = fs.readFileSync(path.join(publicPath, 'foo.glade'), 'utf8') 27 | t.is(f1.trim(), '

foo

') 28 | t.is(f2.trim(), 'p not processed by spike core') 29 | t.is(f3.trim(), 'Glade Air Freshener™ is a really cool product') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/sugarml.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const sugarml = require('sugarml') 5 | const {compileFixture} = require('./_helpers') 6 | 7 | test('works with sugarml parser', (t) => { 8 | return compileFixture(t, 'sugarml', { 9 | matchers: { html: '*(**/)*.sgr' }, 10 | reshape: { 11 | parser: sugarml, 12 | filename: (ctx) => ctx.resourcePath, 13 | locals: {} 14 | } 15 | }).then(({res, publicPath}) => { 16 | const index = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 17 | t.truthy(index === 'wowowowowowoowowowooooowowowowowwwwowoowowowow

oh hello!

') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/template.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const EventEmitter = require('events') 3 | const path = require('path') 4 | const fs = require('fs') 5 | const rimraf = require('rimraf') 6 | const {fixturesPath, debug} = require('./_helpers') 7 | const Spike = require('..') 8 | 9 | test.before((t) => { Spike.template.reset() }) 10 | test.afterEach((t) => { Spike.template.reset() }) 11 | 12 | test.cb('template.add adds a template', (t) => { 13 | const emitter = new EventEmitter() 14 | 15 | emitter.on('info', debug) 16 | 17 | emitter.on('success', (res) => { 18 | t.truthy(res === 'template "test" added') 19 | t.truthy(Object.keys(Spike.template.list()).indexOf('test') > -1) 20 | t.end() 21 | }) 22 | 23 | Spike.template.add({ 24 | name: 'test', 25 | src: 'https://github.com/jescalan/sprout-test-template', 26 | emitter: emitter 27 | }) 28 | }) 29 | 30 | test.cb('template.remove gets rid of a template', (t) => { 31 | const e1 = new EventEmitter() 32 | const e2 = new EventEmitter() 33 | 34 | e1.on('info', debug) 35 | e2.on('info', debug) 36 | 37 | e1.on('success', (res) => { 38 | Spike.template.remove({ name: 'test', emitter: e2 }) 39 | }) 40 | 41 | e2.on('success', (res) => { 42 | t.truthy(res === 'template "test" removed') 43 | t.falsy(Object.keys(Spike.template.list()).indexOf('test') > -1) 44 | t.end() 45 | }) 46 | 47 | Spike.template.add({ 48 | name: 'test', 49 | src: 'https://github.com/jescalan/sprout-test-template', 50 | emitter: e1 51 | }) 52 | }) 53 | 54 | test.cb('template.default sets the default template', (t) => { 55 | const e1 = new EventEmitter() 56 | const e2 = new EventEmitter() 57 | const e3 = new EventEmitter() 58 | const testPath = path.join(fixturesPath, 'new_test') 59 | 60 | e1.on('info', debug) 61 | e2.on('info', debug) 62 | e3.on('info', debug) 63 | 64 | e1.on('success', (res) => { 65 | Spike.template.default({ name: 'test', emitter: e2 }) 66 | }) 67 | 68 | e2.on('success', (res) => { 69 | t.truthy(res === 'template "test" is now the default') 70 | Spike.new({ 71 | root: testPath, 72 | locals: { foo: 'bar' }, 73 | emitter: e3 74 | }) 75 | }) 76 | 77 | e3.on('done', (res) => { 78 | t.truthy(res.config.context, testPath) 79 | const content = fs.readFileSync(path.join(testPath, 'index.html'), 'utf8') 80 | t.truthy(content.trim() === '

basic template: bar

') 81 | rimraf(testPath, t.end) 82 | }) 83 | 84 | Spike.template.add({ 85 | name: 'test', 86 | src: 'https://github.com/jescalan/sprout-test-template', 87 | emitter: e1 88 | }) 89 | }) 90 | 91 | test.cb('template.default errors if template has not been added', (t) => { 92 | const emitter = new EventEmitter() 93 | 94 | emitter.on('error', (res) => { 95 | t.truthy(res === 'template "test" doesn\'t exist') 96 | t.end() 97 | }) 98 | 99 | Spike.template.default({ name: 'test', emitter: emitter }) 100 | }) 101 | 102 | test('default function works without an emitter', (t) => { 103 | const res = Spike.template.default({ name: 'test' }) 104 | t.truthy(res === 'template "test" doesn\'t exist') 105 | }) 106 | -------------------------------------------------------------------------------- /test/vendor.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const rimraf = require('rimraf') 4 | const {fs, compileFixture, fixturesPath} = require('./_helpers') 5 | 6 | test.cb.beforeEach((t) => { 7 | rimraf(path.join(fixturesPath, 'vendor', 'public'), () => { t.end() }) 8 | }) 9 | 10 | test('properly vendors specified files', (t) => { 11 | return compileFixture(t, 'vendor').then(({publicPath}) => { 12 | return fs.readFile(path.join(publicPath, 'keep/file.js'), 'utf8') 13 | }).then((contents) => { 14 | return t.regex(contents, /vendored/) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/watch.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const Spike = require('..') 5 | const {fixturesPath} = require('./_helpers') 6 | 7 | test.cb('watches the project, reloads on modification', (t) => { 8 | const project = new Spike({ 9 | root: path.join(fixturesPath, 'watch'), 10 | server: { open: false } 11 | }) 12 | let i = 0 13 | 14 | project.on('compile', (res) => { 15 | i++ 16 | if (i === 1) { 17 | const file = path.join(fixturesPath, 'watch/index.html') 18 | fs.appendFileSync(file, ' ') 19 | fs.writeFileSync(file, fs.readFileSync(file, 'utf8').trim()) 20 | } 21 | if (i === 2) { 22 | watcher.close() 23 | t.end() 24 | } 25 | }) 26 | 27 | const {watcher} = project.watch() 28 | // make sure the watcher is returned 29 | t.truthy((typeof watcher.startTime) === 'number') 30 | }) 31 | 32 | test.cb('incorporates new file when added while watching', (t) => { 33 | const project = new Spike({ 34 | root: path.join(fixturesPath, 'watch'), 35 | server: { open: false } 36 | }) 37 | let i = 0 38 | const testFile = path.join(fixturesPath, 'watch/test.html') 39 | const testResultFile = path.join(fixturesPath, 'watch/public/test.html') 40 | 41 | project.on('compile', (res) => { 42 | i++ 43 | if (i === 1) { 44 | fs.writeFileSync(testFile, '

test

') 45 | } 46 | if (i === 2) { 47 | const contents = fs.readFileSync(testResultFile, 'utf8') 48 | t.truthy(contents.trim(), '

test

') 49 | fs.unlinkSync(testFile) 50 | fs.unlinkSync(testResultFile) 51 | watcher.close() 52 | t.end() 53 | } 54 | }) 55 | 56 | const {watcher} = project.watch() 57 | }) 58 | --------------------------------------------------------------------------------