Commit dd6018b8 authored by Dan Allen's avatar Dan Allen

Merge issue-32-ui-loader branch into master

resolves #32 add UI loader component

See merge request antora/antora-direct!23
parents 63dd7247 eab43e32
Pipeline #13694993 passed with stages
in 4 minutes and 50 seconds
**/test/fixtures/**/*
......@@ -296,7 +296,7 @@ describe('aggregateContent()', () => {
'',
'Hey World!',
'',
].join('\n'),
].join('\n')
)
})
})
......@@ -312,7 +312,7 @@ describe('aggregateContent()', () => {
'modules/ROOT/content/_attributes.adoc',
'modules/ROOT/content/page-one.adoc',
],
'docs',
'docs'
)
playbook.content.sources.push({ location: repo.location, startPath: repo.startPath })
const corpus = aggregateContent(playbook)
......
'use strict'
const crypto = require('crypto')
const path = require('path')
const _ = require('lodash')
const buffer = require('gulp-buffer')
const download = require('download')
const fs = require('fs-extra')
const streamToArray = require('stream-to-array')
const yaml = require('js-yaml')
const zip = require('gulp-vinyl-zip')
const minimatchAll = require('minimatch-all')
const $files = Symbol('$files')
const $filesIndex = Symbol('$filesIndex')
class UiCatalog {
constructor () {
this[$files] = []
this[$filesIndex] = {}
}
getFiles () {
return this[$files]
}
addFile (file) {
const id = [file.type, ...file.path.split('/')]
if (_.get(this[$filesIndex], id) != null) {
throw new Error('Duplicate file')
}
_.set(this[$filesIndex], id, file)
this[$files].push(file)
}
findByType (type) {
return _.filter(this[$files], { type })
}
}
const localCachePath = path.resolve('.ui-cache')
module.exports = async (playbook) => {
const uiCatalog = new UiCatalog()
let zipPath
if (isRemote(playbook.ui.bundle)) {
const bundleSha1 = sha1(playbook.ui.bundle)
zipPath = path.join(localCachePath, bundleSha1 + '.zip')
const alreadyCached = await fs.pathExists(zipPath)
if (!alreadyCached) {
const bundle = await download(playbook.ui.bundle)
await fs.ensureDir(localCachePath)
fs.writeFileSync(zipPath, bundle)
}
} else {
zipPath = path.join(process.cwd(), playbook.ui.bundle)
}
const zipFilesAndDirsStream = zip.src(zipPath).pipe(buffer())
const zipFilesAndDirs = await streamToArray(zipFilesAndDirsStream)
const uiFiles = getFilesFromStartPath(zipFilesAndDirs, playbook.ui.startPath)
const { uiDesc } = readUiDesc(uiFiles)
let staticFiles
if (uiDesc != null) {
if ((staticFiles = uiDesc.staticFiles) != null && !Array.isArray(staticFiles)) {
staticFiles = [staticFiles]
}
}
uiFiles.forEach((file) => {
if (staticFiles != null && isStaticFile(file, staticFiles)) {
file.type = 'static'
file.out = resolveOut(file, '/')
} else {
file.type = resolveType(file)
if (file.type === 'asset') {
file.out = resolveOut(file, playbook.ui.outputDir)
}
}
uiCatalog.addFile(file)
})
return uiCatalog
}
function isRemote (bundle) {
return bundle.startsWith('http://') || bundle.startsWith('https://')
}
function sha1 (string) {
const shasum = crypto.createHash('sha1')
shasum.update(string)
return shasum.digest('hex')
}
function getFilesFromStartPath (filesAndDirs, startPath) {
return filesAndDirs
.map((file) => {
if (file.isDirectory()) {
return null
}
const rootPath = '/' + file.path
if (!rootPath.startsWith(startPath)) {
return null
}
file.path = path.relative(startPath, rootPath)
return file
})
.filter((file) => file != null)
}
function readUiDesc (files) {
const uiDescFileIndex = _.findIndex(files, { path: 'ui.yml' })
if (uiDescFileIndex === -1) {
return {}
}
const [uiDescFile] = files.splice(uiDescFileIndex, 1)
const uiDesc = yaml.safeLoad(uiDescFile.contents.toString())
return { uiDesc, uiDescFile }
}
function isStaticFile (file, staticFiles) {
return minimatchAll(file.path, staticFiles)
}
function resolveType (file) {
const firstPathSegment = file.path.split('/', 1)[0]
if (firstPathSegment === 'layouts') {
return 'layout'
} else if (firstPathSegment === 'helpers') {
return 'helper'
} else if (firstPathSegment === 'partials') {
return 'partial'
} else {
return 'asset'
}
}
function resolveOut (file, outputDir = '/_') {
const dirname = path.join('/', outputDir, file.dirname)
const basename = file.basename
const outputPath = path.join(dirname, basename)
return { dirname, basename, path: outputPath }
}
This diff was suppressed by a .gitattributes entry.
This diff was suppressed by a .gitattributes entry.
This diff was suppressed by a .gitattributes entry.
/* eslint-env mocha */
'use strict'
const { expect } = require('../../../test/test-utils')
const loadUi = require('../lib/index')
const fs = require('fs')
const http = require('http')
const path = require('path')
const del = require('del')
function testAll (archive, testFunction) {
const playbook = { ui: { startPath: '/' } }
it('with local bundle', () => {
playbook.ui.bundle = path.join('./packages/ui-loader/test/fixtures', archive)
return testFunction(playbook)
})
it('with remote bundle', () => {
playbook.ui.bundle = 'http://localhost:1337/' + archive
return testFunction(playbook)
})
}
function cleanCache () {
del.sync('.ui-cache')
}
describe('loadUi()', () => {
const expectedFilePaths = [
'css/one.css',
'css/two.css',
'fonts/Roboto-Medium.ttf',
'helpers/and.js',
'helpers/or.js',
'images/close.svg',
'images/search.svg',
'layouts/404.hbs',
'layouts/default.hbs',
'partials/footer.hbs',
'partials/header.hbs',
'scripts/01-one.js',
'scripts/02-two.js',
]
let server
beforeEach(() => {
cleanCache()
server = http
.createServer((request, response) => {
const filePath = path.join(process.cwd(), './packages/ui-loader/test/fixtures', request.url)
fs.readFile(filePath, (error, content) => {
if (error) {
throw error
}
const contentType = 'application/zip'
response.writeHead(200, { 'Content-Type': contentType })
response.end(content, 'utf-8')
})
})
.listen(1337)
})
afterEach(() => {
server.close()
cleanCache()
})
describe('should load all files in the UI bundle', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const paths = uiCatalog.getFiles().map((file) => file.path)
expect(paths).to.have.members(expectedFilePaths)
expect(paths).not.to.include('ui.yml')
})
})
})
describe('should load all files in the bundle from specified startPath', () => {
testAll('the-ui-bundle-with-start-path.zip', (playbook) => {
playbook.ui.startPath = '/the-ui-bundle'
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const paths = uiCatalog.getFiles().map((file) => file.path)
expect(paths).to.have.members(expectedFilePaths)
expect(paths).not.to.include('ui.yml')
})
})
})
describe('findByType()', () => {
describe('should discover helpers', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const helpers = uiCatalog.findByType('helper')
helpers.forEach(({ type }) => expect(type).to.equal('helper'))
const helperPaths = helpers.map((file) => file.path)
expect(helperPaths).to.have.members(['helpers/and.js', 'helpers/or.js'])
})
})
})
describe('should discover layouts', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const layouts = uiCatalog.findByType('layout')
layouts.forEach(({ type }) => expect(type).to.equal('layout'))
const layoutPaths = layouts.map((file) => file.path)
expect(layoutPaths).to.have.members(['layouts/404.hbs', 'layouts/default.hbs'])
})
})
})
describe('should discover partials', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const partials = uiCatalog.findByType('partial')
partials.forEach(({ type }) => expect(type).to.equal('partial'))
const partialPaths = partials.map((file) => file.path)
expect(partialPaths).to.have.members(['partials/footer.hbs', 'partials/header.hbs'])
})
})
})
describe('should discover assets', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const uiAssets = uiCatalog.findByType('asset')
uiAssets.forEach(({ type }) => expect(type).to.equal('asset'))
const uiAssetPaths = uiAssets.map((file) => file.path)
expect(uiAssetPaths).to.have.members([
'css/one.css',
'css/two.css',
'fonts/Roboto-Medium.ttf',
'images/close.svg',
'images/search.svg',
'scripts/01-one.js',
'scripts/02-two.js',
])
})
})
})
describe('should differentiate static files from assets', () => {
testAll('the-ui-bundle-with-static-files.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const uiAssets = uiCatalog.findByType('asset')
uiAssets.forEach(({ type }) => expect(type).to.equal('asset'))
const uiAssetPaths = uiAssets.map((file) => file.path)
expect(uiAssetPaths).to.have.members([
'css/one.css',
'css/two.css',
'fonts/Roboto-Medium.ttf',
'foo/bar/hello.json',
'images/close.svg',
'images/search.svg',
'scripts/01-one.js',
'scripts/02-two.js',
])
const staticFiles = uiCatalog.findByType('static')
staticFiles.forEach(({ type }) => expect(type).to.equal('static'))
const staticFilePaths = staticFiles.map((file) => file.path)
expect(staticFilePaths).to.have.members(['foo/two.xml', 'foo/bar/one.xml', 'humans.txt'])
})
})
})
describe('should discover static files when specified with single glob string', () => {
testAll('the-ui-bundle-with-static-files-single-glob.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const staticFiles = uiCatalog.findByType('static')
staticFiles.forEach(({ type }) => expect(type).to.equal('static'))
const staticFilePaths = staticFiles.map((file) => file.path)
expect(staticFilePaths).to.have.members(['foo/two.xml', 'foo/bar/one.xml'])
})
})
})
})
describe('should not set the out property on helpers', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const helpers = uiCatalog.findByType('helper')
helpers.forEach((file) => {
expect(file).not.to.have.property('out')
})
})
})
})
describe('should not set the out property on layouts', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const layouts = uiCatalog.findByType('layout')
layouts.forEach((file) => {
expect(file).not.to.have.property('out')
})
})
})
})
describe('should not set the out property on partials', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const partials = uiCatalog.findByType('partial')
partials.forEach((file) => {
expect(file).not.to.have.property('out')
})
})
})
})
describe('should set the out property on assets', () => {
testAll('the-ui-bundle.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const uiAssets = uiCatalog.findByType('asset')
uiAssets.forEach((file) => {
expect(file).to.have.property('out')
})
const script = uiAssets.find(({ path: p }) => p === 'scripts/01-one.js')
expect(script.out).to.eql({
dirname: '/_/scripts',
basename: '01-one.js',
path: '/_/scripts/01-one.js',
})
})
})
})
describe('should set the out property on assets with custom playbook.ui.outputDir', () => {
testAll('the-ui-bundle.zip', (playbook) => {
playbook.ui.outputDir = '/_ui'
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const uiAssets = uiCatalog.findByType('asset')
uiAssets.forEach((file) => {
expect(file).to.have.property('out')
})
const script = uiAssets.find(({ path }) => path === 'scripts/01-one.js')
expect(script.out).to.eql({
dirname: '/_ui/scripts',
basename: '01-one.js',
path: '/_ui/scripts/01-one.js',
})
})
})
})
describe('should set the out property on static files', () => {
testAll('the-ui-bundle-with-static-files.zip', (playbook) => {
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const staticFiles = uiCatalog.findByType('static')
staticFiles.forEach((file) => {
expect(file).to.have.property('out')
})
const xml = staticFiles.find(({ path }) => path === 'foo/bar/one.xml')
expect(xml.out).to.eql({
dirname: '/foo/bar',
basename: 'one.xml',
path: '/foo/bar/one.xml',
})
})
})
})
it('should use a cache without needing remote access when url is the same', () => {
const playbook = {
ui: {
bundle: 'http://localhost:1337/the-ui-bundle.zip',
startPath: '/',
},
}
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const paths = uiCatalog.getFiles().map((file) => file.path)
expect(paths).to.have.members(expectedFilePaths)
expect(paths).not.to.include('ui.yml')
server.close()
return expect(loadUi(playbook))
.to.be.fulfilled()
.then((uiCatalog) => {
const paths = uiCatalog.getFiles().map((file) => file.path)
expect(paths).to.have.members(expectedFilePaths)
expect(paths).not.to.include('ui.yml')
})
})
})
})
......@@ -281,6 +281,10 @@ balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base64-js@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978"
bcrypt-pbkdf@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
......@@ -291,6 +295,12 @@ beeper@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809"
bl@^1.0.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e"
dependencies:
readable-stream "^2.0.5"
block-stream@*:
version "0.0.9"
resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
......@@ -338,6 +348,18 @@ browser-stdout@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f"
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
buffer@^3.0.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-3.6.0.tgz#a72c936f77b96bf52f5f7e7b467180628551defb"
dependencies:
base64-js "0.0.8"
ieee754 "^1.1.4"
isarray "^1.0.0"
builtin-modules@^1.0.0, builtin-modules@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
......@@ -384,6 +406,15 @@ caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
caw@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/caw/-/caw-2.0.1.tgz#6c3ca071fc194720883c2dc5da9b074bfc7e9e95"
dependencies:
get-proxy "^2.0.0"
isurl "^1.0.0-alpha5"
tunnel-agent "^0.6.0"
url-to-options "^1.0.1"
center-align@^0.1.1:
version "0.1.3"
resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad"
......@@ -530,6 +561,12 @@ commander@2.9.0, commander@^2.9.0:
dependencies:
graceful-readlink ">= 1.0.0"
commander@~2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4"
dependencies:
graceful-readlink ">= 1.0.0"
common-tags@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.4.0.tgz#1187be4f3d4cf0c0427d43f74eef1f73501614c0"
......@@ -552,6 +589,13 @@ concat-stream@^1.6.0:
readable-stream "^2.2.2"
typedarray "^0.0.6"
config-chain@^1.1.11:
version "1.1.11"
resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.11.tgz#aba09747dfbe4c3e70e766a6e41586e1859fc6f2"
dependencies:
ini "^1.3.4"
proto-list "~1.2.1"
console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
......@@ -560,6 +604,10 @@ contains-path@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
content-disposition@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4"
convert-source-map@^1.1.1, convert-source-map@^1.3.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5"
......@@ -654,6 +702,60 @@ decamelize@^1.0.0, decamelize@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
decompress-response@^3.2.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
dependencies:
mimic-response "^1.0.0"
decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1"
dependencies:
file-type "^5.2.0"
is-stream "^1.1.0"
tar-stream "^1.5.2"
decompress-tarbz2@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b"
dependencies:
decompress-tar "^4.1.0"
file-type "^6.1.0"
is-stream "^1.1.0"
seek-bzip "^1.0.5"
unbzip2-stream "^1.0.9"
decompress-targz@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee"
dependencies:
decompress-tar "^4.1.1"
file-type "^5.2.0"
is-stream "^1.1.0"
decompress-unzip@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69"
dependencies:
file-type "^3.8.0"
get-stream "^2.2.0"
pify "^2.3.0"
yauzl "^2.4.2"
decompress@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.0.tgz#7aedd85427e5a92dacfe55674a7c505e96d01f9d"
dependencies:
decompress-tar "^4.0.0"
decompress-tarbz2 "^4.0.0"
decompress-targz "^4.0.0"
decompress-unzip "^4.0.1"
graceful-fs "^4.1.10"
make-dir "^1.0.0"
pify "^2.3.0"
strip-dirs "^2.0.0"
deep-eql@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
......@@ -761,12 +863,36 @@ doctrine@^2.0.0:
esutils "^2.0.2"
isarray "^1.0.0"
download@^6.2.5:
version "6.2.5"
resolved "https://registry.yarnpkg.com/download/-/download-6.2.5.tgz#acd6a542e4cd0bb42ca70cfc98c9e43b07039714"
dependencies:
caw "^2.0.0"
content-disposition "^0.5.2"
decompress "^4.0.0"
ext-name "^5.0.0"
file-type "5.2.0"
filenamify "^2.0.0"
get-stream "^3.0.0"
got "^7.0.0"
make-dir "^1.0.0"
p-event "^1.0.0"
pify "^3.0.0"
duplexer2@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db"
dependencies:
readable-stream "~1.1.9"
duplexer3@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
duplexer@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
duplexify@^3.2.0:
version "3.5.1"
resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.1.tgz#4e1516be68838bc90a49994f0b39a6e5960befcd"
......@@ -939,6 +1065,18 @@ esutils@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
event-stream@^3.3.1:
version "3.3.4"
resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571"
dependencies:
duplexer "~0.1.1"
from "~0"
map-stream "~0.1.0"
pause-stream "0.0.11"
split "0.3"
stream-combiner "~0.0.4"
through "~2.3.1"