diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e79ae5..db51857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.8.0 + + ## 0.7.14 * Cached transforms can be reused regardless of sourcemaps ([#46](https://github.com/gobblejs/gobble/issues/46)) diff --git a/package.json b/package.json index f9fd491..060b000 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gobble", "description": "The last build tool you'll ever need", - "version": "0.7.14", + "version": "0.8.0-edge", "author": "Rich Harris", "license": "MIT", "repository": "https://github.com/gobblejs/gobble", diff --git a/src/nodes/Node.js b/src/nodes/Node.js index 1ff3846..d03aac7 100644 --- a/src/nodes/Node.js +++ b/src/nodes/Node.js @@ -1,13 +1,15 @@ import { EventEmitter2 } from 'eventemitter2'; -import { rimraf } from 'sander'; +import * as crc32 from 'buffer-crc32'; +import { lsrSync, readFileSync, rimraf } from 'sander'; import { join, resolve } from 'path'; import * as requireRelative from 'require-relative'; import { grab, include, map as mapTransform, move } from '../builtins'; -import { Transformer } from './index'; +import { Observer, Transformer } from './index'; import config from '../config'; import GobbleError from '../utils/GobbleError'; import assign from '../utils/assign'; import warnOnce from '../utils/warnOnce'; +import compareBuffers from '../utils/compareBuffers'; import serve from './serve'; import build from './build'; import watch from './watch'; @@ -108,6 +110,39 @@ export default class Node extends EventEmitter2 { return new Transformer( this, include, { patterns, exclude: true }); } + getChanges ( inputdir ) { + const files = lsrSync( inputdir ); + + if ( !this._files ) { + this._files = files; + this._checksums = {}; + + files.forEach( file => { + this._checksums[ file ] = crc32( readFileSync( inputdir, file ) ); + }); + + return files.map( file => ({ file, added: true }) ); + } + + const added = files.filter( file => !~this._files.indexOf( file ) ).map( file => ({ file, added: true }) ); + const removed = this._files.filter( file => !~files.indexOf( file ) ).map( file => ({ file, removed: true }) ); + + const maybeChanged = files.filter( file => ~this._files.indexOf( file ) ); + + let changed = []; + + maybeChanged.forEach( file => { + let checksum = crc32( readFileSync( inputdir, file ) ); + + if ( !compareBuffers( checksum, this._checksums[ file ] ) ) { + changed.push({ file, changed: true }); + this._checksums[ file ] = checksum; + } + }); + + return added.concat( removed ).concat( changed ); + } + grab () { const src = join.apply( null, arguments ); return new Transformer( this, grab, { src }); @@ -140,6 +175,18 @@ export default class Node extends EventEmitter2 { return new Transformer( this, move, { dest }); } + observe ( fn, userOptions ) { + if ( typeof fn === 'string' ) { + fn = tryToLoad( fn ); + } + + return new Observer( this, fn, userOptions ); + } + + observeIf ( condition, fn, userOptions ) { + return condition ? this.observe( fn, userOptions ) : this; + } + serve ( options ) { return serve( this, options ); } @@ -168,6 +215,10 @@ export default class Node extends EventEmitter2 { return new Transformer( this, fn, userOptions ); } + transformIf ( condition, fn, userOptions ) { + return condition ? this.transform( fn, userOptions ) : this; + } + watch ( options ) { return watch( this, options ); } diff --git a/src/nodes/Observer.js b/src/nodes/Observer.js new file mode 100644 index 0000000..a192451 --- /dev/null +++ b/src/nodes/Observer.js @@ -0,0 +1,138 @@ +import * as sander from 'sander'; +import Node from './Node'; +import queue from '../queue'; +import GobbleError from '../utils/GobbleError'; +import assign from '../utils/assign'; +import uid from '../utils/uid'; +import makeLog from '../utils/makeLog'; +import config from '../config'; +import extractLocationInfo from '../utils/extractLocationInfo'; +import { ABORTED } from '../utils/signals'; + +export default class Observer extends Node { + constructor ( input, fn, options, id ) { + super(); + + this.input = input; + + this.fn = fn; + this.options = assign( {}, options ); + + this.name = id || fn.id || fn.name || 'unknown'; + this.id = uid( this.name ); + } + + ready () { + let observation; + + if ( !this._ready ) { + observation = { + node: this, + log: makeLog( this ), + env: config.env, + sander: sander + }; + + this._abort = () => { + this._ready = null; + observation.aborted = true; + }; + + this._ready = this.input.ready().then( inputdir => { + return queue.add( ( fulfil, reject ) => { + this.emit( 'info', { + code: 'TRANSFORM_START', // TODO + progressIndicator: true, + id: this.id + }); + + const start = Date.now(); + let called = false; + + const callback = err => { + if ( called ) return; + called = true; + + if ( observation.aborted ) { + reject( ABORTED ); + } + + else if ( err ) { + let stack = err.stack || new Error().stack; + let { file, line, column } = extractLocationInfo( err ); + + let gobbleError = new GobbleError({ + inputdir, + stack, file, line, column, + message: 'observation failed', + id: this.id, + code: 'TRANSFORMATION_FAILED', // TODO + original: err + }); + + reject( gobbleError ); + } + + else { + this.emit( 'info', { + code: 'TRANSFORM_COMPLETE', // TODO + id: this.id, + duration: Date.now() - start + }); + + fulfil( inputdir ); + } + }; + + try { + observation.changes = this.input.changes || this.getChanges( inputdir ); + + const promise = this.fn.call( observation, inputdir, assign({}, this.options ), callback ); + const promiseIsPromise = promise && typeof promise.then === 'function'; + + if ( !promiseIsPromise && this.fn.length < 3 ) { + throw new Error( `Observer ${this.id} did not return a promise and did not accept callback` ); + } + + if ( promiseIsPromise ) { + promise.then( () => callback(), callback ); + } + } catch ( err ) { + callback( err ); + } + }); + }); + } + + return this._ready; + } + + start () { + if ( this._active ) { + return; + } + + this._active = true; + + // Propagate invalidation events and information + this._oninvalidate = changes => { + this._abort(); + this.emit( 'invalidate', changes ); + }; + + this._oninfo = details => this.emit( 'info', details ); + + this.input.on( 'invalidate', this._oninvalidate ); + this.input.on( 'info', this._oninfo ); + + return this.input.start(); + } + + stop () { + this.input.off( 'invalidate', this._oninvalidate ); + this.input.off( 'info', this._oninfo ); + + this.input.stop(); + this._active = false; + } +} diff --git a/src/nodes/Transformer.js b/src/nodes/Transformer.js index afadbee..853973f 100644 --- a/src/nodes/Transformer.js +++ b/src/nodes/Transformer.js @@ -12,7 +12,6 @@ import makeLog from '../utils/makeLog'; import config from '../config'; import warnOnce from '../utils/warnOnce'; import extractLocationInfo from '../utils/extractLocationInfo'; -import compareBuffers from '../utils/compareBuffers'; import { ABORTED } from '../utils/signals'; export default class Transformer extends Node { @@ -153,39 +152,6 @@ export default class Transformer extends Node { this._active = false; } - getChanges ( inputdir ) { - const files = lsrSync( inputdir ); - - if ( !this._files ) { - this._files = files; - this._checksums = {}; - - files.forEach( file => { - this._checksums[ file ] = crc32( readFileSync( inputdir, file ) ); - }); - - return files.map( file => ({ file, added: true }) ); - } - - const added = files.filter( file => !~this._files.indexOf( file ) ).map( file => ({ file, added: true }) ); - const removed = this._files.filter( file => !~files.indexOf( file ) ).map( file => ({ file, removed: true }) ); - - const maybeChanged = files.filter( file => ~this._files.indexOf( file ) ); - - let changed = []; - - maybeChanged.forEach( file => { - let checksum = crc32( readFileSync( inputdir, file ) ); - - if ( !compareBuffers( checksum, this._checksums[ file ] ) ) { - changed.push({ file, changed: true }); - this._checksums[ file ] = checksum; - } - }); - - return added.concat( removed ).concat( changed ); - } - _cleanup ( latest ) { const dir = join( session.config.gobbledir, this.id ); diff --git a/src/nodes/build/index.js b/src/nodes/build/index.js index f42d1d6..3e34237 100644 --- a/src/nodes/build/index.js +++ b/src/nodes/build/index.js @@ -20,14 +20,30 @@ export default function ( node, options ) { let promise; let previousDetails; - // that does double duty as a promise - task.then = function () { - return promise.then.apply( promise, arguments ); - }; + function build () { + task.emit( 'info', { + code: 'BUILD_START' + }); - task.catch = function () { - return promise.catch.apply( promise, arguments ); - }; + node.on( 'info', details => { + if ( details === previousDetails ) return; + previousDetails = details; + task.emit( 'info', details ); + }); + + node.start(); // TODO this starts a file watcher! need to start without watching + + return node.ready().then( + inputdir => { + node.stop(); + return copydir( inputdir ).to( dest ); + }, + err => { + node.stop(); + throw err; + } + ); + } promise = cleanup( gobbledir ) .then( () => { @@ -43,34 +59,26 @@ export default function ( node, options ) { return cleanup( dest ).then( build ); }, build ); }) - .then( () => { - task.emit( 'complete' ); - session.destroy(); - }) - .catch( err => { - task.emit( 'error', err ); - session.destroy(); - throw err; - }); + .then( + () => { + task.emit( 'complete' ); + session.destroy(); + }, + err => { + session.destroy(); + task.emit( 'error', err ); + throw err; + } + ); - return task; - - function build () { - task.emit( 'info', { - code: 'BUILD_START' - }); - - node.on( 'info', details => { - if ( details === previousDetails ) return; - previousDetails = details; - task.emit( 'info', details ); - }); + // that does double duty as a promise + task.then = function () { + return promise.then.apply( promise, arguments ); + }; - node.start(); // TODO this starts a file watcher! need to start without watching + task.catch = function () { + return promise.catch.apply( promise, arguments ); + }; - return node.ready().then( inputdir => { - node.stop(); - return copydir( inputdir ).to( dest ); - }); - } + return task; } diff --git a/src/nodes/index.js b/src/nodes/index.js index 8182292..7489cf0 100644 --- a/src/nodes/index.js +++ b/src/nodes/index.js @@ -1,5 +1,6 @@ import Source from './Source'; import Merger from './Merger'; +import Observer from './Observer'; import Transformer from './Transformer'; -export { Source, Merger, Transformer }; +export { Source, Merger, Observer, Transformer }; diff --git a/test/scenarios.js b/test/scenarios.js index 33eba75..04387fd 100644 --- a/test/scenarios.js +++ b/test/scenarios.js @@ -5,6 +5,8 @@ var gobble = require( '../tmp' ).default; var sander = require( 'sander' ); var simulateChange = require( './utils/simulateChange' ); +var Promise = sander.Promise; + gobble.cwd( __dirname ); function identity ( input ) { @@ -554,6 +556,50 @@ module.exports = function () { task.on( 'error', done ); }); + it( 'calls observers on initial build', function () { + var observed = 0; + + var source = gobble( 'tmp/foo' ).observe( function ( inputdir, options, done ) { + observed += 1; + done(); + }); + + task = source.build({ + dest: 'tmp/output' + }); + + return task.then( function () { + assert.equal( observed, 1 ); + }); + }); + + it( 'triggers observers on file changes', function ( done ) { + var observed = 0; + + var source = gobble( 'tmp/foo' ); + + task = source.observe( function ( inputdir, options, done ) { + observed += 1; + done(); + }).watch({ + dest: 'tmp/output' + }); + + task.once( 'built', function () { + assert.equal( observed, 1 ); + + task.once( 'built', function () { + assert.equal( observed, 2 ); + done(); + }); + + simulateChange( source, { + type: 'change', + path: 'tmp/foo/foo.md' + }); + }); + }); + it( 'does not use non-existent sourcemap files when reusing cached file transformer results', function ( done ) { var source = gobble( 'tmp/foo' ); @@ -574,7 +620,118 @@ module.exports = function () { }); }); }); - }); + it( 'prevents build completing if observers error', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function () { + error = new Error( 'oh noes!' ); + throw error; + }); + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'prevents build completing if observers fail asynchronously via callback', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function ( inputdir, options, done ) { + error = new Error( 'oh noes!' ); + setTimeout( function () { + done( error ); + }); + }); + + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'prevents build completing if observers fail asynchronously via promise', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function () { + error = new Error( 'oh noes!' ); + return Promise.reject( error ); + }); + + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'skips an observer if condition is false', function () { + var observed = 0; + + function incrementObservedCount () { + observed += 1; + } + + var source = gobble( 'tmp/foo' ).observeIf( false, incrementObservedCount ); + + task = source.build({ + dest: 'tmp/output' + }); + + return task.then( function () { + assert.equal( observed, 0 ); + }); + }); + + it( 'skips a transformer if condition is false', function () { + var source = gobble( 'tmp/foo' ); + + return source + .transformIf( false, function ( input ) { + return input.toUpperCase(); + }) + .build({ + dest: 'tmp/output' + }) + .then( function () { + assert.equal( + sander.readFileSync( 'tmp/foo/foo.md' ).toString(), + sander.readFileSync( 'tmp/output/foo.md' ).toString() + ); + }); + }); + }); };