├── .gitignore ├── .jscsrc ├── .jshintrc ├── .npmignore ├── .travis.yml ├── .zuul.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── bin ├── ensure-tests ├── is-ci ├── test └── zuul-tests ├── index.js ├── lib ├── append.js ├── byteLength.js ├── filter.js ├── fork.js ├── invoke.js ├── join.js ├── map.js ├── prepend.js ├── replace.js ├── split.js └── tap.js ├── package.json └── test ├── append.js ├── byteLength.js ├── filter.js ├── fork.js ├── helpers ├── collect.js └── index.js ├── invoke.js ├── join.js ├── map.js ├── prepend.js ├── replace.js ├── split.js └── tap.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "excludeFiles": ["node_modules/**"], 3 | "requireSpaceAfterKeywords": [ 4 | "if", 5 | "else", 6 | "for", 7 | "while", 8 | "do", 9 | "switch", 10 | "return", 11 | "try", 12 | "catch" 13 | ], 14 | "requireParenthesesAroundIIFE": true, 15 | "requireSpacesInAnonymousFunctionExpression": { 16 | "beforeOpeningRoundBrace": true, 17 | "beforeOpeningCurlyBrace": true 18 | }, 19 | "requireSpacesInNamedFunctionExpression": { 20 | "beforeOpeningCurlyBrace": true 21 | }, 22 | "disallowSpacesInNamedFunctionExpression": { 23 | "beforeOpeningRoundBrace": true 24 | }, 25 | "disallowMultipleVarDecl": true, 26 | "requireBlocksOnNewline": true, 27 | "disallowEmptyBlocks": true, 28 | "disallowSpacesInsideObjectBrackets": true, 29 | "disallowSpacesInsideArrayBrackets": true, 30 | "disallowSpacesInsideParentheses": true, 31 | "disallowQuotedKeysInObjects": true, 32 | "requireCommaBeforeLineBreak": true, 33 | "requireOperatorBeforeLineBreak": [ 34 | "?", 35 | "+", 36 | "-", 37 | "/", 38 | "*", 39 | "=", 40 | "==", 41 | "===", 42 | "!=", 43 | "!==", 44 | ">", 45 | ">=", 46 | "<", 47 | "<=" 48 | ], 49 | "disallowLeftStickedOperators": [ 50 | "?", 51 | "+", 52 | "-", 53 | "/", 54 | "*", 55 | "=", 56 | "==", 57 | "===", 58 | "!=", 59 | "!==", 60 | ">", 61 | ">=", 62 | "<", 63 | "<=" 64 | ], 65 | "disallowRightStickedOperators": [ 66 | "?", 67 | "+", 68 | "/", 69 | "*", 70 | ":", 71 | "=", 72 | "==", 73 | "===", 74 | "!=", 75 | "!==", 76 | ">", 77 | ">=", 78 | "<", 79 | "<=" 80 | ], 81 | "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], 82 | "requireSpaceBeforeBinaryOperators": [ 83 | "+", 84 | "-", 85 | "/", 86 | "*", 87 | "=", 88 | "==", 89 | "===", 90 | "!=", 91 | "!==" 92 | ], 93 | "requireSpaceAfterBinaryOperators": [ 94 | "+", 95 | "-", 96 | "/", 97 | "*", 98 | "=", 99 | "==", 100 | "===", 101 | "!=", 102 | "!==" 103 | ], 104 | "requireCamelCaseOrUpperCaseIdentifiers": true, 105 | "disallowMultipleLineStrings": true, 106 | "validateLineBreaks": "LF", 107 | "validateIndentation": 2, 108 | "disallowMixedSpacesAndTabs": true, 109 | "disallowTrailingWhitespace": true, 110 | "disallowKeywordsOnNewLine": ["else"], 111 | "requireLineFeedAtFileEnd": true, 112 | "requireCapitalizedConstructors": true, 113 | "requireDotNotation": true, 114 | "validateJSDoc": { 115 | "checkParamNames": true, 116 | "checkRedundantParams": true, 117 | "requireParamTypes": true 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | // Global 4 | "passfail": false, // Do not stop on first error. 5 | "globals": { 6 | "it": false, // Mocha test cases 7 | "describe": false // Mocha test groups 8 | }, 9 | 10 | // Environment 11 | "node": true, 12 | 13 | // Core 14 | "asi": true, // Tolerate Automatic Semicolon Insertion (no semicolons). 15 | "laxbreak": true, // Tolerate unsafe line breaks e.g. `return [\n] x` without semicolons. 16 | "laxcomma": true, // Tolerate leading commas. 17 | "bitwise": false, // Prohibit bitwise operators (&, |, ^, etc.). 18 | "boss": true, // Tolerate assignments inside if, for & while. 19 | "curly": false, // Require {} for every new block or scope. 20 | "eqeqeq": false, // Require triple equals i.e. `===`. 21 | "eqnull": true, // Tolerate use of `== null`. 22 | "evil": false, // Tolerate use of `eval`. 23 | "forin": false, // Tolerate `for in` loops without `hasOwnPrototype`. 24 | "immed": false, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );` 25 | "latedef": false, // Prohipit variable use before definition. 26 | "loopfunc": true, // Allow functions to be defined within loops. 27 | "noarg": true, // Prohibit use of `arguments.caller` and `arguments.callee`. 28 | "regexp": false, // Prohibit `.` and `[^...]` in regular expressions. 29 | "expr": true, // allow boss style expressions (foo && foo()) 30 | "regexdash": true, // Tolerate unescaped last dash i.e. `[-...]`. 31 | "shadow": false, // Allows re-define variables later in code e.g. `var x=1; x=2;`. 32 | "supernew": false, // Tolerate `new function () { ... };` and `new Object;`. 33 | "undef": true, // Require all non-global variables be declared before they are used. 34 | "strict": false, // This option enables strict mode for function scope only 35 | "maxerr": 1000, 36 | 37 | // Obvious style preferences. 38 | "newcap": true, // Require capitalization of all constructor functions e.g. `new F()`. 39 | "noempty": false, // Prohibit use of empty blocks. 40 | "nonew": true, // Prohibit use of constructors for side-effects. 41 | "onevar": false, // Allow only one `var` statement per function. 42 | "plusplus": false, // Prohibit use of `++` & `--`. 43 | "sub": true, // Tolerate all forms of subscript notation besides dot notation e.g. `dict['key']` 44 | "trailing": true, // Prohibit trailing whitespaces. 45 | "indent": 2, // Specify indentation spacing 46 | "white": true, // This checks indentation rules, but is incompatible with the comma-first style :( 47 | "smarttabs": false, // no mixed tabs and spaces! use spaces! 48 | "onecase": true, // allow switch statements with just one case 49 | "unused": true, // Prohibit unused variables 50 | "trailing": true // No trailing whitespace 51 | } 52 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | bin/ 3 | 4 | .travis.yml 5 | .zuul.yml 6 | .jscsrc 7 | .jshintrc 8 | .gitignore 9 | CONTRIBUTING.md 10 | README.md 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.11' 4 | - '0.10' 5 | env: 6 | global: 7 | - secure: vcT1IFYGLo/1Uvr8DNkoSLf9ZJdZvQKrENHOiXpLwVpdcTbXZ1sMGCvzSIcE+YK9UJTQksx0YvN4XwfzTmMzhR5+YR5+t5MSkeBaTriLgPLsr+FzCKQkU+L5OnlrjsWPzBsu2ECjJfgdgLrxkh2cgMpcOQXhpPwh1SMZR9lu+zk= 8 | - secure: p/haYvsG+P+hU281Kc+7kQFsBxZ5A0XyKcXwCyx5WCzlA8H9/SpuDJCzTs+OE6Rh90IyrC66tm+lq1HohNC3I5bnwsAzTJa90PajapZFtTyVhvFN3t1SfB9N4JZM1bVJX/+8M0iE4H9hHG6NR4SlwY3/m51/zObk37RhCo3Vkas= 9 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | name: sculpt 2 | ui: mocha-bdd 3 | browsers: 4 | - name: chrome 5 | version: latest 6 | - name: ie 7 | version: latest 8 | - name: firefox 9 | version: latest 10 | - name: safari 11 | version: latest 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Questions, comments, bug reports, and pull requests are all welcome. Submit them at [the project on GitHub](https://github.com/Medium/sculpt/). If you haven't contributed to a [Medium](http://github.com/Medium/) project before please head over to the [Open Source Project](https://github.com/Medium/open-source#note-to-external-contributors) and fill out an contributor license agreement (it should be pretty painless). 4 | 5 | Bug reports that include steps-to-reproduce (including code) are the best. Even better, make them in the form of pull requests. 6 | 7 | ## Workflow 8 | 9 | ### Pull requests 10 | 11 | [Fork](https://github.com/Medium/sculpt/fork) the project on GitHub and make a pull request from your feature branch against the upstream master branch. Consider rebasing your branch onto the latest master before sending a pull request to make sure there are no merge conflicts, failing tests, or other regressions. 12 | 13 | ### Code style 14 | 15 | Your code should pass JS Hint (part of the `npm test` script). When in doubt, try to follow existing conventions and these basic rules: 16 | 17 | * Don't use semi-colons. We think our code looks sleeker without them. 18 | * Indent using two spaces. Never use tabs. 19 | * Delete trailing whitespace. It's ugly. 20 | * Use spaces after the `function` keyword, like this: `function () {}` 21 | 22 | 23 | ## Documentation 24 | 25 | ### JSDoc 26 | 27 | We use Closure-style [JSDoc](https://developers.google.com/closure/compiler/docs/js-for-compiler) for inline documentation. There is no formal guideline, but please try to follow the existing conventions for documentation. For example, function paramaters and return values should always be documented, and functions should also have a brief, clear description. 28 | 29 | Use examples and in-line documentation when they're helpful. Avoid comments like `This adds 1 to the variable "i"`. 30 | 31 | ### Readme 32 | 33 | If you introduce changes or new features that will affect users, consider updating or adding the relevant section of the [readme](https://github.com/Medium/sculpt/blob/master/README.md). 34 | 35 | ## Tests 36 | 37 | ### Unit tests 38 | 39 | Tests use [Mocha](http://visionmedia.github.io/mocha/) and can be run with `npm test`. Tests will automatically be run on [Travis CI](https://travis-ci.org/Medium/sculpt) for new pull requests, and pull requests will only be merged if the tests pass. 40 | 41 | New features and bug fixes should have new unit tests. Don't be afraid to make the tests fun to read, we will all be fine without another example of asserting "foobar" or "example data". I like Vampire Weekend lyrics. Be creative. 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 A Medium Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sculpt 2 | 3 | [![Build Status](https://secure.travis-ci.org/Medium/sculpt.svg?branch=master)](http://travis-ci.org/Medium/sculpt) 4 | 5 | A collection of Node.js [transform stream](http://nodejs.org/api/stream.html#stream_class_stream_transform) 6 | utilities for simple data manipulation. 7 | 8 | Install with `npm install sculpt --save`. 9 | 10 | ## API 11 | 12 | All of Sculpt's streams operate in `objectMode`, so be careful that you know what data types are 13 | going in and coming out of your streams. Normally Node.js streams are guaranteed to be strings or 14 | buffers, but that is not the case when streams operate in object mode. 15 | 16 | **Methods** 17 | 18 | *Builders* 19 | * [Map](#map) 20 | * [Filter](#filter) 21 | 22 | *Strings* 23 | 24 | * [Append](#append) 25 | * [Prepend](#prepend) 26 | * [Replace](#replace) 27 | * [Split](#split) 28 | * [Byte Length](#byte-length) 29 | 30 | *Objects* 31 | 32 | * [Join](#join) 33 | * [Invoke](#invoke) 34 | 35 | *Control Flow* 36 | 37 | * [Fork](#fork) 38 | * [Tap](#tap) 39 | 40 | *Miscellaneous* 41 | 42 | * [Pipes](#pipes) 43 | 44 | ### Map 45 | 46 | **Arguments** 47 | 48 | * callback: A function to apply to each chunk. The functions result is injected into the stream 49 | in place of the chunk. 50 | 51 | ```javascript 52 | var stream = sculpt.map(function (chunk) { 53 | return chunk + chunk 54 | }) 55 | 56 | stream.pipe(process.stdout) 57 | stream.write('hello') 58 | 59 | // hellohello 60 | ``` 61 | 62 | Map can also operate asynchronously. To make the stream async, pass a second argument 63 | (a done callback) and call `.async()`. 64 | 65 | ```javascript 66 | var stream = sculpt.map(function (chunk, done) { 67 | requestRemoteData(chunk, function (err, data) { 68 | done(err, chunk + data) 69 | }) 70 | }).async() 71 | 72 | stream.pipe(process.stdout) 73 | stream.write('hello') 74 | 75 | // 'hello some remote data...' 76 | ``` 77 | 78 | Map streams can also operate in multi mode, which lets them push multiple unique values 79 | in a single callback. Callbacks in multi mode **must** return arrays, and each item 80 | will be pushed individually. To create a map steam in multi mode call `.multi()`. 81 | 82 | This is most useful when you're consuming the output with another stream that depends on 83 | meaningful items in each push. This is how the split stream is implemented. 84 | 85 | ```javascript 86 | var i = 0 87 | var stream = sculpt.map(function (chunk) { 88 | i++ 89 | return [i.toString(), chunk] 90 | }).multi() 91 | 92 | stream.pipe(process.stdout) 93 | stream.write('hello') 94 | 95 | // 1hello 96 | ``` 97 | 98 | Map streams can be set to ignore values that are `undefined`. Ordinarily Node.js treats `null`-ish 99 | values (including `undefined`) as signaling the end of a stream. In some cases it's useful to be 100 | able to avoid pushing data for some inputs without having a separate stream to filter the data — for 101 | example, cases where deciding whether you want to push data requires expensive computation. In 102 | those cases, you can set the stream to ignore `undefined` values. 103 | 104 | ```javascript 105 | var stream = sculpt.map(function (chunk) { 106 | if (chunk === 'hello') return 107 | return chunk 108 | }).ignoreUndefined() 109 | 110 | stream.pipe(process.stdout) 111 | stream.write('hello') 112 | strea.write('world') 113 | 114 | // world 115 | ``` 116 | 117 | 118 | ### Filter 119 | 120 | **Arguments** 121 | * callback: A truth test to apply to each chunk. If the callback returns false, the chunk 122 | is removed from the stream. 123 | 124 | ```javascript 125 | var stream = sculpt.filter(function (chunk) { 126 | return chunk.toString().length >= 5 127 | }) 128 | 129 | stream.on('data', console.log.bind(console)) 130 | stream.write('hi') 131 | stream.write('hello') 132 | stream.write('goodbye') 133 | 134 | // 'hellogoodbye' 135 | ``` 136 | 137 | Filter can also operate asynchronously. To make the stream async, pass a second argument 138 | (a done callback) and call `.async()`. 139 | 140 | ```javascript 141 | var stream = sculpt.filter(function (chunk, done) { 142 | requestRemoteValidation(chunk, function (err, valid) { 143 | done(err, !! valid) 144 | }) 145 | }).async() 146 | 147 | stream.on('data', console.log.bind(console)) 148 | stream.write('hi') 149 | stream.write('hello') 150 | stream.write('goodbye') 151 | 152 | // 'hellogoodbye' 153 | ``` 154 | 155 | ### Append 156 | 157 | **Arguments** 158 | 159 | * str: String to append to each chunk. 160 | 161 | ```javascript 162 | var stream = sculpt.append('!!') 163 | 164 | stream.on('data', console.log.bind(console)) 165 | stream.write('hello') 166 | stream.write('world') 167 | 168 | // 'hello!!world!!' 169 | ``` 170 | 171 | ### Prepend 172 | 173 | **Arguments** 174 | 175 | * str: String to prepend to each chunk. 176 | 177 | ```javascript 178 | var stream = sculpt.prepend('> ') 179 | 180 | stream.pipe(process.stdout) 181 | stream.write('hello\n') 182 | stream.write('world') 183 | 184 | // > hello 185 | // > world 186 | ``` 187 | 188 | ### Replace 189 | 190 | **Arguments** 191 | 192 | * find: String or regex to search for in each chunk. 193 | * replace: String or function to replace the found value with. 194 | 195 | ```javascript 196 | var stream = sculpt.replace('!', '?') 197 | 198 | stream.pipe(process.stdout) 199 | stream.write('hello! ') 200 | stream.write('world ') 201 | stream.write('goodbye!') 202 | 203 | // 'hello? world goodbye?' 204 | ``` 205 | 206 | ### Join 207 | 208 | **Arguments** 209 | 210 | * str: A string to join each element in the chunk by. 211 | 212 | This is intended to be used on arrays, but could work on any data type that has a `join()` method. 213 | 214 | ```javascript 215 | var stream = sculpt.join('|') 216 | 217 | stream.pipe(process.stdout) 218 | stream.write([1, 2, 3]) 219 | stream.write(['foo', 'bar']) 220 | 221 | // '1|2|3foo|bar' 222 | ``` 223 | 224 | ### Invoke 225 | 226 | **Arguments** 227 | 228 | * methodName: A method to call on each chunk. 229 | * args: Optional, arguments to pass to the named method 230 | 231 | ```javascript 232 | var stream = sculpt.invoke('toString') 233 | 234 | stream.pipe(process.stdout) 235 | stream.end(123) 236 | 237 | // '123' 238 | ``` 239 | 240 | ### Split 241 | 242 | **Arguments** 243 | 244 | * str: A string to split each element in the chunk on. 245 | 246 | This is intended to be used on strings (and create arrays), but could work on any data type that 247 | has a `split()` method. 248 | 249 | ```javascript 250 | var stream = sculpt.split('|') 251 | var partNumber = 0 252 | stream.on('data', function (part) { 253 | partNumber++ 254 | console.log(partNumber, part) 255 | }) 256 | 257 | stream.write('hi|bye|foo|bar') 258 | 259 | // '1 hi' 260 | // '2 bye' 261 | // '3 foo' 262 | // '4 bar' 263 | ``` 264 | 265 | ### Byte Length 266 | **Arguments** 267 | 268 | * length: Length in bytes for each output chunk 269 | 270 | Each output chunk will be a buffer of `length` bytes, except the last chunk, which will be however many bytes are left over. 271 | 272 | ```javascript 273 | var stream = sculpt.byteLength(5) 274 | stream.on('data', function (chunk) { 275 | console.log(chunk.toString()) 276 | }) 277 | stream.end('abcdefghijk') 278 | 279 | // 'abcde' 280 | // 'fghij' 281 | // 'k' 282 | ``` 283 | 284 | ### Fork 285 | 286 | **Arguments** 287 | 288 | * stream: A writable stream that will also receive writes passed to this transform stream. 289 | 290 | Errors from the forked stream are bubbled up to this transform stream. 291 | 292 | ```javascript 293 | var stream = sculpt.fork(process.stderr) 294 | 295 | stream.pipe(process.stderr) 296 | stream.write('hello world') 297 | 298 | // 'hello world' is output to stdout and stderr 299 | ``` 300 | 301 | ### Tap 302 | 303 | **Arguments** 304 | 305 | * callback: A side effect function that is called with each chunk. It's return value is ignored 306 | and the chunk is propagated along the stream, unchanged. 307 | 308 | ```javascript 309 | var count = 0 310 | var stream = tap(function (item) { 311 | if (item === 'bump') { 312 | count++ 313 | } 314 | }) 315 | 316 | stream.on('end', function () { 317 | console.log('Count is %d', count) 318 | }) 319 | 320 | stream.write('bump') 321 | stream.write('bump') 322 | stream.write('hello') 323 | stream.write('bump') 324 | 325 | // 'Count is 3' 326 | ``` 327 | 328 | ### Pipes 329 | 330 | Transform streams can be piped together. Let's say you have a file with song lyrics and you want to clean it up. 331 | 332 | ```javascript 333 | fs.createReadStream('./lyrics.txt') 334 | 335 | // Split into individual lines 336 | // The following streams will operate on one line at a time. 337 | .pipe(sculpt.split('\n')) 338 | 339 | // Remove trailing whitespace from each line 340 | .pipe(sculpt.replace(/\s+$/, '')) 341 | 342 | // Remove empty lines 343 | .pipe(sculpt.filter(function (line) { 344 | return line.length > 0 345 | })) 346 | 347 | // Bring back line breaks 348 | .pipe(sculpt.append('\n')) 349 | 350 | // Print the result 351 | .pipe(process.stdout) 352 | ``` 353 | -------------------------------------------------------------------------------- /bin/ensure-tests: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | var fs = require('fs') 4 | var path = require('path') 5 | 6 | var libPath = path.resolve(__dirname, '../lib') 7 | var testPath = path.resolve(__dirname, '../test') 8 | 9 | var libFiles = fs.readdirSync(libPath) 10 | var testFiles = fs.readdirSync(testPath) 11 | 12 | console.log('Ensuring all lib files have tests.') 13 | 14 | libFiles.forEach(function (libFile) { 15 | if (testFiles.indexOf(libFile) > -1) return 16 | console.error('Missing tests for: %s', libFile) 17 | console.error('All lib files should have matching test files.') 18 | process.exit(1) 19 | }) 20 | 21 | console.log('All lib files have tests. Good.') 22 | -------------------------------------------------------------------------------- /bin/is-ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | if (process.env.TRAVIS === 'true' && process.env.CI === 'true') { 4 | process.exit(0) 5 | } else { 6 | process.exit(1) 7 | } 8 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Fail on first error and print each command 4 | set -ex 5 | 6 | # Make sure every lib module has a corresponding test 7 | npm run ensure-tests 8 | 9 | # Lint and style 10 | npm run jshint 11 | npm run jscs 12 | 13 | # Node tests 14 | npm run mocha 15 | 16 | # Browser tests 17 | npm run zuul 18 | -------------------------------------------------------------------------------- /bin/zuul-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run in phantomjs unless on CI 4 | 5 | ./bin/is-ci 6 | if [[ "$?" -eq "0" ]]; then 7 | echo "On CI, running browser tests on SauceLabs" 8 | ./node_modules/.bin/zuul -- test/*.js 9 | else 10 | echo "Running browser tests locally with phantomjs" 11 | ./node_modules/.bin/zuul --phantom -- test/*.js 12 | fi 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview Export all of the public modules. 5 | */ 6 | 7 | module.exports = { 8 | append : require('./lib/append'), 9 | byteLength : require('./lib/byteLength'), 10 | filter : require('./lib/filter'), 11 | fork : require('./lib/fork'), 12 | invoke : require('./lib/invoke'), 13 | join : require('./lib/join'), 14 | map : require('./lib/map'), 15 | prepend : require('./lib/prepend'), 16 | replace : require('./lib/replace'), 17 | split : require('./lib/split'), 18 | tap : require('./lib/tap') 19 | } 20 | -------------------------------------------------------------------------------- /lib/append.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview An implementaion of the Mapper stream. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Creates a Mapper stream that appends a suffix to each chunk. 10 | * 11 | * @param {String} suffix Appended to each chunk 12 | * @return {Mapper} 13 | */ 14 | module.exports = function (suffix) { 15 | var mapper = function (chunk) { 16 | return chunk.toString() + suffix 17 | } 18 | 19 | return map(mapper) 20 | } 21 | -------------------------------------------------------------------------------- /lib/byteLength.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that splits streams into chunks of the same size in bytes. 5 | * Buffer splitting is based on https://github.com/substack/rolling-hash. 6 | */ 7 | var map = require('./map') 8 | 9 | /** 10 | * Create a Mapper stream that splits incoming data into equal size chunks. The last buffer will 11 | * likely be smaller than the size, based on whatever is left. Length is always calculated as 12 | * Buffers, output is always Buffer objects. 13 | * @param {Number} length 14 | * @return {Mapper} 15 | */ 16 | module.exports = function (length) { 17 | var cache = [] 18 | var cachedBytes = 0 19 | 20 | var transform = function (buf) { 21 | // Make sure we're always dealing with Buffers 22 | if (! Buffer.isBuffer(buf)) { 23 | buf = new Buffer(buf) 24 | } 25 | 26 | // Check to see if we have enough data to push out to consumers 27 | // If not, cache this chunk and move on. 28 | if (buf.length + cachedBytes < length) { 29 | cache.push(buf) 30 | cachedBytes += buf.length 31 | return map.EMPTY_ARRAY 32 | } 33 | 34 | // Check to see if we have any previously cached data to combine with this chunk 35 | if (cachedBytes) { 36 | cache.push(buf) 37 | buf = Buffer.concat(cache) 38 | cachedBytes = 0 39 | cache.length = 0 40 | } 41 | 42 | // Build an array of chunks that are the desired length 43 | var correctLengthChunks = [] 44 | for (var i = 0; i <= buf.length - length; i += length) { 45 | correctLengthChunks.push(buf.slice(i, i + length)) 46 | } 47 | 48 | // Cache any leftover data 49 | var extraBytes = buf.length % length 50 | if (extraBytes) { 51 | cache.push(buf.slice(buf.length - extraBytes)) 52 | cachedBytes += extraBytes 53 | } 54 | 55 | return correctLengthChunks 56 | } 57 | 58 | var flush = function () { 59 | return cache 60 | } 61 | 62 | return map(transform, flush).multi() 63 | } 64 | -------------------------------------------------------------------------------- /lib/filter.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that removes chunks when the test function 5 | * returns a falsey value. 6 | */ 7 | var map = require('./map') 8 | 9 | /** 10 | * Transform stream that passes on chunks that pass the test function's truth test. 11 | * @param {Function} fn Determines whether a chunk is kept or removed based on return value. 12 | * @return {Mapper} 13 | */ 14 | module.exports = function (fn) { 15 | return map(function (data, callback) { 16 | if (! this.isAsync()) { 17 | var syncResult = fn(data) ? [data] : map.EMPTY_ARRAY 18 | return syncResult 19 | } 20 | 21 | fn(data, function (err, valid) { 22 | var asyncResult = valid ? [data] : map.EMPTY_ARRAY 23 | callback(err, asyncResult) 24 | }) 25 | }).multi() 26 | } 27 | -------------------------------------------------------------------------------- /lib/fork.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that forks written values to a writable stream. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Create a transform stream that forks incoming writes. 10 | * @param {stream.Writable} writable 11 | * @return {Mapper} 12 | */ 13 | module.exports = function (writable) { 14 | var stream = map(function (data, cb) { 15 | writable.write(data, function (err) { 16 | cb(err, data) 17 | }) 18 | }).async() 19 | 20 | // Bubble errors from the forked stream to the parent. 21 | writable.on('error', function () { 22 | var args = Array.prototype.slice.call(arguments) 23 | stream.emit.apply(stream, ['error'].concat(args)) 24 | }) 25 | 26 | return stream 27 | } 28 | -------------------------------------------------------------------------------- /lib/invoke.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that calls a method on each incoming chunk. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Create a Mapper stream that calls a method on each chunk. 10 | * @param {String} methodName 11 | * @param {...*=} args 12 | * @return {Mapper} 13 | */ 14 | module.exports = function (methodName, args) { 15 | // All arguments after the method name 16 | args = Array.prototype.slice.call(arguments, 1) 17 | 18 | return map(function (chunk) { 19 | return chunk[methodName].apply(chunk, args) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /lib/join.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that joins incoming data with a separator 5 | */ 6 | var invoke = require('./invoke') 7 | 8 | /** 9 | * Create a Mapper stream that joins incoming data. 10 | * @param {String} separator 11 | * @return {Mapper} 12 | */ 13 | module.exports = function (separator) { 14 | return invoke('join', separator) 15 | } 16 | -------------------------------------------------------------------------------- /lib/map.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that maps each chunk before it's written. 5 | */ 6 | var util = require('util') 7 | var Transform = require('stream').Transform 8 | 9 | /** 10 | * Transform stream that applies a mapper function to each chunk and pushes 11 | * the mapped result. 12 | * @param {Function} mapper 13 | * @param {Function=} flusher 14 | */ 15 | function Mapper(mapper, flusher) { 16 | Transform.call(this, { 17 | objectMode: true, 18 | // Patch from 0.11.7 19 | // https://github.com/joyent/node/commit/ba72570eae938957d10494be28eac28ed75d256f 20 | highWaterMark: 16 21 | }) 22 | 23 | this.mapper = mapper 24 | this.flusher = flusher 25 | } 26 | util.inherits(Mapper, Transform) 27 | 28 | /** 29 | * @override 30 | */ 31 | Mapper.prototype._transform = function () { 32 | var method = this.isAsync() ? '_asyncTransform' : '_syncTransform' 33 | this[method].apply(this, arguments) 34 | } 35 | 36 | /** 37 | * @override 38 | */ 39 | Mapper.prototype._flush = function (callback) { 40 | // If we don't have a flusher, bail. 41 | if (! this.flusher) return callback() 42 | 43 | var method = this.isAsync() ? '_asyncFlush' : '_syncFlush' 44 | this[method].call(this, callback) 45 | } 46 | 47 | /** 48 | * Apply a syncronous transformation. 49 | * @param {*} chunk 50 | * @param {String} enc 51 | * @param {Function} callback 52 | */ 53 | Mapper.prototype._syncTransform = function (chunk, enc, callback) { 54 | try { 55 | this.push(this.mapper(chunk)) 56 | callback() 57 | } catch (e) { 58 | callback(e) 59 | } 60 | } 61 | 62 | /** 63 | * Apply an asyncronous transformation. 64 | * @param {*} chunk 65 | * @param {String} enc 66 | * @param {Function} callback 67 | */ 68 | Mapper.prototype._asyncTransform = function (chunk, enc, callback) { 69 | this.mapper(chunk, function (err, data) { 70 | if (err) return callback(err) 71 | 72 | this.push(data) 73 | callback(null) 74 | }.bind(this)) 75 | } 76 | 77 | /** 78 | * Synchronously flush the stream. 79 | * @param {Function} callback 80 | */ 81 | Mapper.prototype._syncFlush = function (callback) { 82 | try { 83 | this.push(this.flusher()) 84 | callback() 85 | } catch (e) { 86 | callback(e) 87 | } 88 | } 89 | 90 | /** 91 | * Asynchronously flush the stream. 92 | * @param {Function} callback 93 | */ 94 | Mapper.prototype._asyncFlush = function (callback) { 95 | this.flusher(function (err, data) { 96 | if (err) return callback(err) 97 | 98 | this.push(data) 99 | callback(null) 100 | }.bind(this)) 101 | } 102 | 103 | /** 104 | * Set the stream to be async. This is only relevant for map and filter streams, and not 105 | * for the built in streams that implement them. 106 | * 107 | * @return {Mapper} 108 | */ 109 | Mapper.prototype.async = function () { 110 | this._inAsyncMode = true 111 | return this 112 | } 113 | 114 | /** 115 | * Determine whether the stream is in async mode. 116 | * 117 | * @return {Boolean} 118 | */ 119 | Mapper.prototype.isAsync = function () { 120 | return !! this._inAsyncMode 121 | } 122 | 123 | /** 124 | * Set the stream to allow pushing multiple values per call. This is only relevant for map streams, 125 | * and not for the built in streams that implement them. 126 | * 127 | * @return {Mapper} 128 | */ 129 | Mapper.prototype.multi = function () { 130 | this._inMultiMode = true 131 | return this 132 | } 133 | 134 | /** 135 | * Determine whether the stream is in multi mode. 136 | * 137 | * @return {Boolean} 138 | */ 139 | Mapper.prototype.isMulti = function () { 140 | return !! this._inMultiMode 141 | } 142 | 143 | /** 144 | * Set the stream to ignore undefined values when they are written. This helps avoid accidentally 145 | * signaling the end of a stream. 146 | * 147 | * @return {Mapper} 148 | */ 149 | Mapper.prototype.ignoreUndefined = function () { 150 | this._ignoringUndefined = true 151 | return this 152 | } 153 | 154 | /** 155 | * Determing whether the stream is ignoring undefined values. 156 | * 157 | * @return {Boolean} 158 | */ 159 | Mapper.prototype.isIgnoringUndefined = function () { 160 | return !! this._ignoringUndefined 161 | } 162 | 163 | /** 164 | * Wrapper for the core readable stream's push method. Allows pushing multiple values in 165 | * a single call for streams in multi mode. 166 | * 167 | * @override 168 | * @param {*} data Data to push. Should be an array when the stream is in multi mode. 169 | * @return {Boolean} 170 | */ 171 | Mapper.prototype.push = function (data) { 172 | var push = Mapper.super_.prototype.push 173 | 174 | // If we're in multi-mode but get undefined while we're ignoring undefined values, bail. 175 | // In this case we don't give Node a chance to tell us whether or not the stream is in a 176 | // state to accept more pushes. Since we didn't add any data to the buffer, it's as safe 177 | // to push more data as it was to push this one. If it's not safe to push more data then 178 | // it wasn't safe to push this data, so you're probably ignoring this value anyway so 179 | // who cares what we return. ¯\_(ツ)_/¯ 180 | if (this._shouldSkipPush(data)) { 181 | return true 182 | } 183 | 184 | // The null condition here is because internally Node writes a null value to indicate 185 | // the end of the stream. 186 | if (! this.isMulti() || data === null) { 187 | return push.call(this, data) 188 | } else { 189 | var needMoreData = false 190 | for (var i = 0; i < data.length; ++i) { 191 | needMoreData = push.call(this, data[i]) 192 | } 193 | return needMoreData 194 | } 195 | } 196 | 197 | /** 198 | * Determine whether we should skip the push step for this chunk of data. 199 | * 200 | * @param {*} data 201 | * @return {Boolean} 202 | */ 203 | Mapper.prototype._shouldSkipPush = function (data) { 204 | return data === undefined && this.isIgnoringUndefined() 205 | } 206 | 207 | /** 208 | * Create a Mapper stream. 209 | * @param {Function} mapper 210 | * @param {Function=} flush 211 | * @return {Mapper} 212 | */ 213 | module.exports = function (mapper, flush) { 214 | return new Mapper(mapper, flush) 215 | } 216 | 217 | /** 218 | * Cache a single empty, immutable array. Used in streams that implement multi() to 219 | * avoid creating a new object each time we need an empty array. 220 | * @type {Array} 221 | * @constant 222 | */ 223 | module.exports.EMPTY_ARRAY = Object.freeze([]) 224 | -------------------------------------------------------------------------------- /lib/prepend.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that prepends a prefix to each chunk. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Create a Mapper stream that prefixes chunks. 10 | * @param {String} prefix 11 | * @return {Mapper} 12 | */ 13 | module.exports = function (prefix) { 14 | var mapper = function (chunk) { 15 | return prefix + chunk.toString() 16 | } 17 | 18 | return map(mapper) 19 | } 20 | -------------------------------------------------------------------------------- /lib/replace.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that calls .replace() on each chunk. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Create a Mapper stream that replaces values in each chunk. 10 | * @param {String|RegExp} find 11 | * @param {String|Function} replace 12 | * @return {Mapper} 13 | */ 14 | module.exports = function (find, replace) { 15 | var mapper = function (chunk) { 16 | return chunk.toString().replace(find, replace) 17 | } 18 | 19 | return map(mapper) 20 | } 21 | -------------------------------------------------------------------------------- /lib/split.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that splits chunks by separators. 5 | */ 6 | var map = require('./map') 7 | 8 | /** 9 | * Create a Mapper stream that splits incoming data. 10 | * @param {String|RegExp} separator 11 | * @return {Mapper} 12 | */ 13 | module.exports = function (separator) { 14 | var cache = '' 15 | 16 | var mapper = function (chunk) { 17 | cache += chunk.toString() 18 | var parts = cache.split(separator) 19 | cache = parts.pop() 20 | return parts 21 | } 22 | 23 | var flush = function () { 24 | return [cache] 25 | } 26 | 27 | return map(mapper, flush).multi() 28 | } 29 | -------------------------------------------------------------------------------- /lib/tap.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A transform stream that calls a function for each chunk but 5 | * does not change the streaming data. 6 | */ 7 | var map = require('./map') 8 | 9 | /** 10 | * Create a Mapper stream that calls the side effect function and then 11 | * passes on the unchanged chunk. 12 | * @param {Function} fn 13 | * @return {Mapper} 14 | */ 15 | module.exports = function (fn) { 16 | var mapper = function (chunk) { 17 | fn(chunk) 18 | return chunk 19 | } 20 | 21 | return map(mapper) 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sculpt", 3 | "version": "0.1.7", 4 | "description": "Generate Node 0.10-friendly transform streams to manipulate other streams.", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=0.10.0" 8 | }, 9 | "engineStrict": true, 10 | "scripts": { 11 | "ensure-tests": "bin/ensure-tests", 12 | "jshint": "jshint lib/* test/* index.js", 13 | "jscs": "jscs lib/* test/* index.js", 14 | "mocha": "mocha --reporter spec --check-leaks", 15 | "zuul": "bin/zuul-tests", 16 | "test": "bin/test" 17 | }, 18 | "devDependencies": { 19 | "es5-shim": "^4.0.3", 20 | "jscs": "~1.3.0", 21 | "jshint": "~2.4.4", 22 | "mocha": "~1.17.1", 23 | "phantomjs": "^1.9.10", 24 | "zuul": "^1.11.0" 25 | }, 26 | "author": "Evan Solomon ", 27 | "license": "MIT", 28 | "homepage": "https://github.com/Medium/sculpt", 29 | "repository": { 30 | "type": "git", 31 | "url": "git://github.com/Medium/sculpt.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/Medium/sculpt/issues" 35 | }, 36 | "keywords": [ 37 | "stream", 38 | "streams2", 39 | "transform", 40 | "objectMode" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /test/append.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Append', function () { 6 | it('Should add a suffix', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.append('?') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.deepEqual([ 14 | 'Don\'t you know that it\'s insane?', 15 | 'Don\'t you want to get out of Cap Code, out of Cape Code tonight?' 16 | ], collector.getObjects()) 17 | done() 18 | }) 19 | 20 | stream.write('Don\'t you know that it\'s insane') 21 | stream.write('Don\'t you want to get out of Cap Code, out of Cape Code tonight') 22 | stream.end() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/byteLength.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | function makeStreams(length) { 6 | var collector = helpers.collect() 7 | collector.on('data', function () {}) 8 | var byteLengthStream = helpers.sculpt.byteLength(length) 9 | byteLengthStream.pipe(collector) 10 | 11 | return { 12 | byteLength: byteLengthStream, 13 | collector: collector 14 | } 15 | } 16 | 17 | describe('Bytes', function () { 18 | it('Should output a single chunk if input is less than min bytes', function (done) { 19 | var streams = makeStreams(10) 20 | streams.collector.on('end', function () { 21 | var output = streams.collector.getObjects() 22 | helpers.assert.equal(1, output.length) 23 | helpers.assert.equal('Ya Hey', output[0]) 24 | done() 25 | }) 26 | 27 | streams.byteLength.end('Ya Hey') 28 | }) 29 | 30 | it('Should output a single chunk if input is exactly min bytes', function (done) { 31 | var input = 'So I could never love you' 32 | var streams = makeStreams(Buffer.byteLength(input)) 33 | 34 | streams.collector.on('end', function () { 35 | var output = streams.collector.getObjects() 36 | helpers.assert.equal(1, output.length) 37 | helpers.assert.equal(input, output[0]) 38 | done() 39 | }) 40 | 41 | streams.byteLength.end(input) 42 | }) 43 | 44 | it('Should output Buffer objects', function (done) { 45 | var streams = makeStreams(10) 46 | streams.collector.on('end', function () { 47 | var output = streams.collector.getObjects() 48 | output.map(function (chunk) { 49 | helpers.assert.ok(Buffer.isBuffer(chunk)) 50 | }) 51 | done() 52 | }) 53 | 54 | streams.byteLength.end('In spite of everything') 55 | }) 56 | 57 | it('Should output chunks of the correct length', function (done) { 58 | var streams = makeStreams(5) 59 | streams.collector.on('end', function () { 60 | var output = streams.collector.getObjects() 61 | for (var i = 0; i < output.length - 1; i++) { 62 | helpers.assert.equal(output[i].length, 5) 63 | } 64 | 65 | // Last chunk can be any length 66 | helpers.assert.ok(output.pop().length <= 5) 67 | done() 68 | }) 69 | 70 | streams.byteLength.write('In the dark of this place') 71 | streams.byteLength.write('There\'s the glow of your face') 72 | streams.byteLength.write('There\'s the dust on the screen') 73 | streams.byteLength.write('Of this broken machine') 74 | streams.byteLength.end() 75 | }) 76 | 77 | it('Should split on byte length, not character count', function (done) { 78 | var streams = makeStreams(1) 79 | var multiByteChar = "0xDFFF" 80 | var byteLength = Buffer.byteLength(multiByteChar) 81 | 82 | streams.collector.on('end', function () { 83 | var chunks = streams.collector.getObjects() 84 | helpers.assert.equal(chunks.length, byteLength) 85 | done() 86 | }) 87 | 88 | streams.byteLength.end(multiByteChar) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/filter.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | // Remove all references to New Jersey 6 | function noJersey(item) { 7 | return item.indexOf('Garden State') === -1 8 | } 9 | 10 | describe('Filter', function () { 11 | it('Should allow objects that pass', function (done) { 12 | var collector = helpers.collect() 13 | var stream = helpers.sculpt.filter(noJersey) 14 | 15 | stream.pipe(collector) 16 | stream.on('error', done) 17 | collector.on('end', function () { 18 | helpers.assert.equal('Out of Cape Cod tonight', collector.getObjects().pop()) 19 | done() 20 | }) 21 | 22 | stream.end('Out of Cape Cod tonight') 23 | }) 24 | 25 | it('Should allow objects that pass async', function (done) { 26 | var collector = helpers.collect() 27 | var stream = helpers.sculpt.filter(function (chunk, cb) { 28 | setTimeout(function () { 29 | cb(null, noJersey(chunk)) 30 | }, 1) 31 | }).async() 32 | 33 | stream.pipe(collector) 34 | stream.on('error', done) 35 | collector.on('end', function () { 36 | helpers.assert.equal('Out of Cape Cod tonight', collector.getObjects().pop()) 37 | done() 38 | }) 39 | 40 | stream.end('Out of Cape Cod tonight') 41 | }) 42 | 43 | it('Should block objects that do not pass', function (done) { 44 | var collector = helpers.collect() 45 | var stream = helpers.sculpt.filter(noJersey) 46 | 47 | stream.pipe(collector) 48 | stream.on('error', done) 49 | collector.on('end', function () { 50 | helpers.assert.deepEqual([], collector.getObjects()) 51 | done() 52 | }) 53 | 54 | stream.end('All the way to the Garden State') 55 | }) 56 | 57 | it('Should block objects that do not pass async', function (done) { 58 | var collector = helpers.collect() 59 | var stream = helpers.sculpt.filter(function (chunk, cb) { 60 | setTimeout(function () { 61 | cb(null, noJersey(chunk)) 62 | }, 1) 63 | }).async() 64 | 65 | stream.pipe(collector) 66 | stream.on('error', done) 67 | collector.on('end', function () { 68 | helpers.assert.deepEqual([], collector.getObjects()) 69 | done() 70 | }) 71 | 72 | stream.end('All the way to the Garden State') 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/fork.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Fork', function () { 6 | it('Should fork to another writable stream', function (done) { 7 | var collector = helpers.collect() 8 | var forkedWritable = helpers.collect() 9 | var stream = helpers.sculpt.fork(forkedWritable) 10 | 11 | stream.pipe(collector) 12 | stream.on('error', done) 13 | collector.on('end', function () { 14 | helpers.assert.deepEqual(['The Holy Roman Empire', 'roots for you'], forkedWritable.getObjects()) 15 | helpers.assert.deepEqual(['The Holy Roman Empire', 'roots for you'], collector.getObjects()) 16 | done() 17 | }) 18 | 19 | stream.write('The Holy Roman Empire') 20 | stream.write('roots for you') 21 | stream.end() 22 | }) 23 | 24 | it('Should bubble errors', function (done) { 25 | var stream = helpers.sculpt.fork(helpers.collect()) 26 | stream.on('error', function (err) { 27 | helpers.assert.equal(err.message, 'write after end') 28 | done() 29 | }) 30 | 31 | stream.end('Hyannis Port is a ghetto, ') 32 | stream.end('out of Cape Cod tonight') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/helpers/collect.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | /** 4 | * @fileoverview A simple transform stream that just collects incoming objects. 5 | */ 6 | var sculpt = require('../../index') 7 | 8 | /** 9 | * Create a collector stream. 10 | * @return {stream.Transform} 11 | */ 12 | module.exports = function () { 13 | var objects = [] 14 | var collector = sculpt.tap(objects.push.bind(objects)) 15 | 16 | // Make sure it consumes the incoming data 17 | collector.on('data', function () {}) 18 | 19 | collector.getObjects = function () { 20 | return objects 21 | } 22 | 23 | return collector 24 | } 25 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collect: require('./collect'), 3 | sculpt: require('../../'), 4 | assert: require('assert'), 5 | shim: require('es5-shim') 6 | } 7 | -------------------------------------------------------------------------------- /test/invoke.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Method', function () { 6 | it('Should call a method', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.invoke('toString') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.strictEqual(collector.getObjects().shift(), '11') 14 | done() 15 | }) 16 | 17 | stream.end(11) 18 | }) 19 | 20 | it('Should emit an error if the method does not exist', function (done) { 21 | var collector = helpers.collect() 22 | var stream = helpers.sculpt.invoke('fake') 23 | 24 | stream.pipe(collector) 25 | stream.on('error', function (err) { 26 | helpers.assert.ok(err) 27 | helpers.assert.ok(err.message.indexOf('has no method')) 28 | done() 29 | }) 30 | 31 | stream.end('A stranger walked in through the door') 32 | }) 33 | 34 | it('Should pass arbitrary arguments to the method', function (done) { 35 | var collector = helpers.collect() 36 | var stream = helpers.sculpt.invoke('slice', 1) 37 | 38 | stream.pipe(collector) 39 | stream.on('error', done) 40 | collector.on('end', function () { 41 | helpers.assert.deepEqual(collector.getObjects().shift(), [ 42 | 'all', 43 | 'apartments', 44 | 'are', 45 | 'pre-war' 46 | ]) 47 | done() 48 | }) 49 | 50 | stream.end([ 51 | 'Said', 52 | 'all', 53 | 'apartments', 54 | 'are', 55 | 'pre-war' 56 | ]) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /test/join.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Join', function () { 6 | it('Should join with a separator', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.join(' about ') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.deepEqual([ 14 | 'Why would you lie about how much coal you have?', 15 | 'Why would you lie about something dumb like that?' 16 | ], collector.getObjects()) 17 | done() 18 | }) 19 | 20 | stream.write(['Why would you lie', 'how much coal you have?']) 21 | stream.write(['Why would you lie', 'something dumb like that?']) 22 | stream.end() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/map.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Map', function () { 6 | it('Should apply a mapper', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.map(function (line) { 9 | return line.toUpperCase() 10 | }) 11 | 12 | stream.pipe(collector) 13 | stream.on('error', done) 14 | collector.on('end', function () { 15 | helpers.assert.deepEqual([ 16 | 'WHY WOULD YOU LIE ABOUT HOW MUCH COAL YOU HAVE?', 17 | 'WHY WOULD YOU LIE ABOUT ANYTHING AT ALL?' 18 | ], collector.getObjects()) 19 | done() 20 | }) 21 | 22 | stream.write('Why would you lie about how much coal you have?') 23 | stream.write('Why would you lie about anything at all?') 24 | stream.end() 25 | }) 26 | 27 | it('Should apply an async mapper', function (done) { 28 | var collector = helpers.collect() 29 | var stream = helpers.sculpt.map(function (line, cb) { 30 | setTimeout(function () { 31 | cb(null, line.toUpperCase()) 32 | }, 1) 33 | }).async() 34 | 35 | stream.pipe(collector) 36 | stream.on('error', done) 37 | collector.on('end', function () { 38 | helpers.assert.deepEqual([ 39 | 'WHY WOULD YOU LIE ABOUT HOW MUCH COAL YOU HAVE?', 40 | 'WHY WOULD YOU LIE ABOUT ANYTHING AT ALL?' 41 | ], collector.getObjects()) 42 | done() 43 | }) 44 | 45 | stream.write('Why would you lie about how much coal you have?') 46 | stream.write('Why would you lie about anything at all?') 47 | stream.end() 48 | }) 49 | 50 | it('Should apply a multi mapper', function (done) { 51 | var collector = helpers.collect() 52 | var i = 0 53 | var stream = helpers.sculpt.map(function (line) { 54 | i++ 55 | return [i, line] 56 | }).multi() 57 | 58 | stream.pipe(collector) 59 | stream.on('error', done) 60 | collector.on('end', function () { 61 | helpers.assert.deepEqual([ 62 | 1, 63 | 'Why would you lie about how much coal you have?', 64 | 2, 65 | 'Why would you lie about anything at all?' 66 | ], collector.getObjects()) 67 | done() 68 | }) 69 | 70 | stream.write('Why would you lie about how much coal you have?') 71 | stream.write('Why would you lie about anything at all?') 72 | stream.end() 73 | }) 74 | 75 | it('Should apply an async multi mapper', function (done) { 76 | var collector = helpers.collect() 77 | var i = 0 78 | var stream = helpers.sculpt.map(function (line, cb) { 79 | setTimeout(function () { 80 | i++ 81 | cb(null, [i, line]) 82 | }, 1) 83 | }).async().multi() 84 | 85 | stream.pipe(collector) 86 | stream.on('error', done) 87 | collector.on('end', function () { 88 | helpers.assert.deepEqual([ 89 | 1, 90 | 'Why would you lie about how much coal you have?', 91 | 2, 92 | 'Why would you lie about anything at all?' 93 | ], collector.getObjects()) 94 | done() 95 | }) 96 | 97 | stream.write('Why would you lie about how much coal you have?') 98 | stream.write('Why would you lie about anything at all?') 99 | stream.end() 100 | }) 101 | 102 | it('Should not throw on pushing data when async streams have an error', function (done) { 103 | var stream = helpers.sculpt.map(function (data, callback) { 104 | callback(new Error('This stream never works')) 105 | }).async().multi() 106 | 107 | stream.on('error', function (err) { 108 | helpers.assert.ok(err) 109 | done() 110 | }) 111 | 112 | stream.on('end', function () { 113 | done(new Error('This stream should error before it ends')) 114 | }) 115 | 116 | stream.end('I see a Mansard roof through the trees') 117 | }) 118 | 119 | it('Should be able to ignore undefined values', function (done) { 120 | var stream = helpers.sculpt.map(function (num) { 121 | return num % 2 ? num : undefined 122 | }).ignoreUndefined() 123 | var collector = helpers.collect() 124 | 125 | stream.on('error', done) 126 | collector.on('error', done) 127 | stream.pipe(collector) 128 | 129 | stream.write(1) 130 | stream.write(2) 131 | stream.write(3) 132 | stream.write(4) 133 | stream.end() 134 | 135 | collector.on('end', function () { 136 | helpers.assert.deepEqual([1, 3], collector.getObjects()) 137 | done() 138 | }) 139 | }) 140 | 141 | it('Should flush', function (done) { 142 | var stream = helpers.sculpt.map(function (i) { 143 | return i 144 | }, function () { 145 | return 'Finish' 146 | }) 147 | var collector = helpers.collect() 148 | 149 | stream.on('error', done) 150 | collector.on('error', done) 151 | stream.pipe(collector) 152 | 153 | stream.end('Start') 154 | 155 | collector.on('end', function () { 156 | helpers.assert.deepEqual(['Start', 'Finish'], collector.getObjects()) 157 | done() 158 | }) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /test/prepend.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Prepend', function () { 6 | it('Should add a prefix', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.prepend('Why would you lie about ') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.deepEqual([ 14 | 'Why would you lie about how much coal you have?', 15 | 'Why would you lie about something dumb like that?', 16 | 'Why would you lie about anything at all?' 17 | ], collector.getObjects()) 18 | done() 19 | }) 20 | 21 | stream.write('how much coal you have?') 22 | stream.write('something dumb like that?') 23 | stream.write('anything at all?') 24 | stream.end() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/replace.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Replace', function () { 6 | it('Should replace a string with a string', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.replace('ride on', 'it\'s a light on') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.equal('Baby baby baby baby it\'s a light on', collector.getObjects().pop()) 14 | done() 15 | }) 16 | 17 | stream.end('Baby baby baby baby ride on') 18 | }) 19 | 20 | it('Should replace a regex with a string', function (done) { 21 | var collector = helpers.collect() 22 | var stream = helpers.sculpt.replace(/baby .+$/, 'it\'s a light on') 23 | 24 | stream.pipe(collector) 25 | stream.on('error', done) 26 | collector.on('end', function () { 27 | helpers.assert.equal('Baby it\'s a light on', collector.getObjects().pop()) 28 | done() 29 | }) 30 | 31 | stream.end('Baby baby baby baby ride on') 32 | }) 33 | 34 | it('Should replace a regex with a function', function (done) { 35 | var collector = helpers.collect() 36 | // Capitalize the word after the last "baby" 37 | var stream = helpers.sculpt.replace(/baby(?! baby) (\w+)/, function (match, p1) { 38 | return 'baby ' + p1.toUpperCase() 39 | }) 40 | 41 | stream.pipe(collector) 42 | stream.on('error', done) 43 | collector.on('end', function () { 44 | helpers.assert.equal('Baby baby baby baby RIDE on', collector.getObjects().pop()) 45 | done() 46 | }) 47 | 48 | stream.end('Baby baby baby baby ride on') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test/split.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Split', function () { 6 | it('Should split on a string', function (done) { 7 | var collector = helpers.collect() 8 | var stream = helpers.sculpt.split('\n') 9 | 10 | stream.pipe(collector) 11 | stream.on('error', done) 12 | collector.on('end', function () { 13 | helpers.assert.deepEqual([ 14 | 'I took your counsel and came to ruin', 15 | 'Leave me to myself, leave me to myself' 16 | ], collector.getObjects()) 17 | done() 18 | }) 19 | 20 | stream.end('I took your counsel and came to ruin\nLeave me to myself, leave me to myself') 21 | }) 22 | 23 | it('Should split on a regex', function (done) { 24 | var collector = helpers.collect() 25 | var stream = helpers.sculpt.split(/,\s+/) 26 | 27 | stream.pipe(collector) 28 | stream.on('error', done) 29 | collector.on('end', function () { 30 | helpers.assert.deepEqual([ 31 | 'Looked up full of fear', 32 | 'trapped beneath the chandelier' 33 | ], collector.getObjects()) 34 | done() 35 | }) 36 | 37 | stream.end('Looked up full of fear, trapped beneath the chandelier') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/tap.js: -------------------------------------------------------------------------------- 1 | // Copyright 2014. A Medium Corporation 2 | 3 | var helpers = require('./helpers') 4 | 5 | describe('Tap', function () { 6 | it('Should call a side effect function without changing data', function (done) { 7 | var collector = helpers.collect() 8 | var hannahs = 0 9 | var stream = helpers.sculpt.tap(function (line) { 10 | if (line.indexOf('Hannah') > -1) { 11 | hannahs++ 12 | } 13 | }) 14 | 15 | stream.pipe(collector) 16 | stream.on('error', done) 17 | collector.on('end', function () { 18 | helpers.assert.equal(hannahs, 2) 19 | done() 20 | }) 21 | 22 | stream.write('In Santa Barbara, Hannah cried') 23 | stream.write('I miss those freezing beaches') 24 | stream.write('And I walked into town') 25 | stream.write('To buy some kindling for the fire') 26 | stream.write('Hannah tore the New York Times up into pieces') 27 | stream.end() 28 | }) 29 | }) 30 | --------------------------------------------------------------------------------