Przeglądaj źródła

Merge branch 'master' into t/41

Maksymilian Barnaś 9 lat temu
rodzic
commit
c60ae22646

+ 1 - 0
.gitignore

@@ -9,5 +9,6 @@
 
 node_modules/**
 build/**
+bundle/**
 coverage/**
 dist/**

+ 1 - 1
dev/tasks/build/iconmanagermodel.tpl

@@ -5,7 +5,7 @@
 
 'use strict';
 
-import Model from '/ckeditor5/ui/model.js';
+import Model from '../ckeditor5/ui/model.js';
 
 const iconIds = [ {{#shapes}}'{{name}}',{{/shapes}} ];
 const iconPrefix = 'ck-icon-';

+ 14 - 3
dev/tasks/build/tasks.js

@@ -26,9 +26,14 @@ module.exports = ( config ) => {
 		clean: {
 			/**
 			 * Removes "themes" folder from "./build/{format}" directory.
+			 *
+			 * @param {Object} options
+			 * @param {String} options.formats
 			 */
-			themes() {
-				return utils.clean( buildDir, path.join( `@(${ utils.parseArguments().formats.join( '|' ) })`, 'theme' ) );
+			themes( options = {} ) {
+				let formats = options.formats || args.formats;
+
+				return utils.clean( buildDir, path.join( `@(${ formats.join( '|' ) })`, 'theme' ) );
 			},
 
 			/**
@@ -332,9 +337,15 @@ module.exports = ( config ) => {
 			gulp.task( 'build:icons', () => tasks.build.icons( args ) );
 			gulp.task( 'build:js', [ 'build:clean:js' ], () => tasks.build.js( args ) );
 
-			// Tasks specific for `gulp docs` builder.
+			// Tasks specific for preparing build with unmodified source files. Uses by `gulp docs` or `gulp bundle`.
 			gulp.task( 'build:clean:js:esnext', () => tasks.clean.js( { formats: [ 'esnext' ] } ) );
+			gulp.task( 'build:clean:themes:esnext', () => tasks.clean.themes( { formats: [ 'esnext' ] } ) );
+			gulp.task( 'build:sass:esnext', () => tasks.build.sass( { formats: [ 'esnext' ] } ) );
+			gulp.task( 'build:icons:esnext', () => tasks.build.icons( { formats: [ 'esnext' ] } ) );
 			gulp.task( 'build:js:esnext', [ 'build:clean:js:esnext' ], () => tasks.build.js( { formats: [ 'esnext' ] } ) );
+			gulp.task( 'build:themes:esnext', ( callback ) => {
+				runSequence( 'build:clean:themes:esnext', 'build:icons:esnext', 'build:sass:esnext', callback );
+			} );
 
 			// Tasks specific for testing under node.
 			gulp.task( 'build:clean:js:cjs', () => tasks.clean.js( { formats: [ 'cjs' ] } ) );

+ 4 - 19
dev/tasks/build/utils.js

@@ -17,11 +17,11 @@ const PassThrough = require( 'stream' ).PassThrough;
 const through = require( 'through2' );
 const fs = require( 'fs' );
 const sass = require( 'node-sass' );
-const del = require( 'del' );
 const minimist = require( 'minimist' );
 const sprite = require( 'gulp-svg-sprite' );
 const pipe = require( 'multipipe' );
 const filter = require( 'gulp-filter' );
+const mainUtils = require( '../utils' );
 
 const utils = {
 	/**
@@ -135,7 +135,7 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 	 */
 	transpile( format, options ) {
 		return gulpBabel( options )
-			.on( 'error', function( err ) {
+			.on( 'error', ( err ) => {
 				gutil.log( gutil.colors.red( `Error (Babel:${ format })` ) );
 				gutil.log( gutil.colors.red( err.message ) );
 				console.log( '\n' + err.codeFrame + '\n' );
@@ -490,22 +490,6 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 	},
 
 	/**
-	 * Removes files and directories specified by `glob` starting from `rootDir`
-	 * and gently informs about deletion.
-	 *
-	 * @param {String} rootDir The path to the root directory (i.e. "dist/").
-	 * @param {String} glob Glob specifying what to clean.
-	 * @returns {Promise}
-	 */
-	clean( rootDir, glob ) {
-		return del( path.join( rootDir, glob ) ).then( paths => {
-			paths.forEach( p => {
-				gutil.log( `Deleted file '${ gutil.colors.cyan( p ) }'.` );
-			} );
-		} );
-	},
-
-	/**
 	 * Parses command line arguments and returns them as a user-friendly hash.
 	 *
 	 * @returns {Object} options
@@ -628,4 +612,5 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 	}
 };
 
-module.exports = utils;
+// Extend top level utils.
+module.exports = Object.assign( utils, mainUtils );

+ 185 - 0
dev/tasks/bundle/tasks.js

@@ -0,0 +1,185 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+const fs = require( 'fs' );
+const path = require( 'path' );
+const gulp = require( 'gulp' );
+const gulpCssnano = require( 'gulp-cssnano' );
+const gulpUglify = require( 'gulp-uglify' );
+const gutil = require( 'gulp-util' );
+const runSequence = require( 'run-sequence' );
+const utils = require( './utils' );
+const rollup = require( 'rollup' ).rollup;
+const rollupBabel = require( 'rollup-plugin-babel' );
+const mkdirp = require( 'mkdirp' );
+
+module.exports = ( config ) => {
+	const args = utils.parseArguments();
+	const sourceBuildDir = path.join( config.ROOT_DIR, config.BUILD_DIR, 'esnext' );
+	const bundleDir = path.join( config.ROOT_DIR, config.BUNDLE_DIR );
+	const bundleTmpDir = path.join( bundleDir, 'tmp' );
+	const temporaryEntryFilePath = path.join( bundleTmpDir, 'entryfile.js' );
+
+	const tasks = {
+		/**
+		 * Removes all files from bundle directory.
+		 */
+		clean() {
+			return utils.clean( bundleDir, '*.*' );
+		},
+
+		/**
+		 * Combines whole editor files into two files `ckeditor.js` and `ckeditor.css`.
+		 *
+		 * For JS bundle is required to pass configuration file `gulp bundle --config path/to/file.js` where
+		 * we need to specify which editor and features we want to include in our bundle.
+		 *
+		 *		// example-config-file.js:
+		 *
+		 *		module.exports = {
+		 *			// Name of CKEditor instance exposed as global variable by a bundle.
+		 *			moduleName: 'MyCKEditor',
+		 *
+		 *			// Path to specified editor module.
+		 *			// It could be a path relative to `build/esnext/ckeditor5` directory e.g. `editor-classic/classic`
+		 *			// or path relative to directory where build task will be executed `./full/path/to/custom/editor`.
+		 *			//
+		 *			// Note: file extension is appended automatically.
+		 *			editor: 'editor-classic/classic',
+		 *
+		 *			// List of features.
+		 *			//
+		 *			// Note: file extension is appended automatically.
+		 *			features: [
+		 *				// It could be a plugin name only if plugin is a default CKEditor plugin.
+		 *				'delete',
+		 *
+		 *				// It could be a path relative to `build/esnext/ckeditor5` directory.
+		 *				`typing/typing`,
+		 *
+		 *				// Or it could be a path relative to directory where build task will be executed.
+		 *				'./path/to/some/custom/feature',
+		 *			]
+		 *		};
+		 *
+		 * @param {String} configFilePath Path to the bundle configuration file.
+		 * @returns {Promise} Promise that resolve bundling for CSS and JS.
+		 */
+		generate( configFilePath ) {
+			// When config file is not specified.
+			if ( !configFilePath ) {
+				// Then log error.
+				gutil.log( gutil.colors.red( `Bundle Error: Path to the config file is required. 'gulp bundle --config path/to/file.js'` ) );
+
+				// And stop task as failed.
+				return Promise.reject();
+			}
+
+			// Get configuration from the configuration file.
+			const config = require( path.resolve( '.', configFilePath ) );
+
+			// Create a temporary entry file with proper directory structure if not exist.
+			mkdirp.sync( bundleTmpDir );
+			fs.writeFileSync( temporaryEntryFilePath, utils.renderEntryFileContent( bundleTmpDir, config ) );
+
+			// Bundling JS by Rollup.
+			function bundleJS() {
+				const outputFile = path.join( bundleDir, 'ckeditor.js' );
+
+				return rollup( {
+					entry: temporaryEntryFilePath,
+					plugins: [
+						rollupBabel( {
+							presets: [ 'es2015-rollup' ]
+						} )
+					]
+				} )
+				.then( ( bundle ) => {
+					return bundle.write( {
+						dest: outputFile,
+						format: 'iife',
+						moduleName: config.moduleName
+					} );
+				} )
+				.then( () => {
+					// If everything went well then remove tmp directory.
+					utils.clean( bundleTmpDir, path.join( '' ) );
+				} )
+				.catch( ( err ) => {
+					// If something went wrong then log error.
+					gutil.log( gutil.colors.red( err.stack ) );
+
+					// And remove tmp directory.
+					utils.clean( bundleTmpDir, path.join( '' ) );
+
+					throw new Error( 'Build error.' );
+				} );
+			}
+
+			// CSS is already bundled by a build task, so we need only to copy it.
+			function bundleCss() {
+				const cssSource = path.join( sourceBuildDir, 'theme', 'ckeditor.css' );
+
+				return utils.copyFile( cssSource, bundleDir );
+			}
+
+			// Lets wait for both - JS and CSS.
+			return Promise.all( [ bundleJS(), bundleCss() ] );
+		},
+
+		minify: {
+			/**
+			 * JS minification by UglifyJS.
+			 */
+			js() {
+				let stream = gulp.src( path.join( bundleDir, 'ckeditor.js' ) )
+					.pipe( gulpUglify() );
+
+				return utils.saveFileFromStreamAsMinified( stream, bundleDir );
+			},
+
+			/**
+			 * CSS minification by cssnano.
+			 */
+			css() {
+				let stream = gulp.src( path.join( bundleDir, 'ckeditor.css' ) )
+					.pipe( gulpCssnano() );
+
+				return utils.saveFileFromStreamAsMinified( stream, bundleDir );
+			}
+		},
+
+		register() {
+			gulp.task( 'bundle:clean', tasks.clean );
+			gulp.task( 'bundle:generate',
+				[
+					'bundle:clean',
+					'build:js:esnext',
+					'build:themes:esnext'
+				],
+				() => tasks.generate( args.config )
+			);
+			gulp.task( 'bundle:minify:js', tasks.minify.js );
+			gulp.task( 'bundle:minify:css', tasks.minify.css );
+
+			gulp.task( 'bundle', ( callback ) => {
+				runSequence( 'bundle:generate', [ 'bundle:minify:js', 'bundle:minify:css' ], () => {
+					const files = [ 'ckeditor.js', 'ckeditor.css', 'ckeditor.min.js', 'ckeditor.min.css' ];
+					const filesStats = utils.getFilesSizeStats( files, bundleDir );
+
+					// Show bundle summary on console.
+					utils.showFilesSummary( 'Bundle summary', filesStats );
+
+					// Finish the task.
+					callback();
+				} );
+			} );
+		}
+	};
+
+	return tasks;
+};

+ 240 - 0
dev/tasks/bundle/utils.js

@@ -0,0 +1,240 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+const fs = require( 'fs' );
+const path = require( 'path' );
+const gulp = require( 'gulp' );
+const gulpRename = require( 'gulp-rename' );
+const gutil = require( 'gulp-util' );
+const prettyBytes = require( 'pretty-bytes' );
+const gzipSize = require( 'gzip-size' );
+const mainUtils = require( '../utils' );
+const minimist = require( 'minimist' );
+
+const utils = {
+	/**
+	 * Parses command line arguments and returns them as a user-friendly hash.
+	 *
+	 * @returns {Object} options
+	 * @returns {String} [options.config] Path to the bundle configuration file.
+	 */
+	parseArguments() {
+		const options = minimist( process.argv.slice( 2 ), {
+			string: [
+				'config'
+			]
+		} );
+
+		return options;
+	},
+
+	/**
+	 * When module path is not relative then treat this path as a path to the one of the ckeditor5 default module
+	 * (relative to ./bundle/exnext/ckeditor5) and add prefix `./build/esnext/ckeditor5/` to this path.
+	 *
+	 * This method also adds `.js` extension.
+	 *
+	 * @param {String} modulePath Path to the module (without extension).
+	 */
+	getModuleFullPath( modulePath ) {
+		// If path is not a relative path (no leading ./ or ../).
+		if ( modulePath.charAt( 0 ) != '.' ) {
+			return `./${ path.join( 'build/esnext/ckeditor5', modulePath ) }.js`;
+		}
+
+		return modulePath + '.js';
+	},
+
+	/**
+	 * Resolves a simplified plugin name to a real path if only name is passed.
+	 * E.g. 'delete' will be transformed to './build/esnext/ckeditor5/delete/delete.js'.
+	 *
+	 * @param {String} name
+	 * @returns {String} Path to the module.
+	 */
+	getPluginPath( name ) {
+		if ( name.indexOf( '/' ) >= 0 ) {
+			return utils.getModuleFullPath( name );
+		}
+
+		return utils.getModuleFullPath( `${ name }/${ name }` );
+	},
+
+	/**
+	 * Transforms first letter of passed string to the uppercase.
+	 *
+	 * @param {String} string String that will be transformed.
+	 * @returns {String} Transformed string.
+	 */
+	capitalize( string ) {
+		return string.charAt( 0 ).toUpperCase() + string.slice( 1 );
+	},
+
+	/**
+	 * Renders content for the entry file which needs to be passed as main file to the Rollup.
+	 *
+	 * @param {String} dir Path to the entryfile directory. Import paths need to be relative to this directory.
+	 * @param {Object} data Configuration object.
+	 * @param {String} [data.moduleName] Name of the editor class exposed as global variable by bundle. e.g. MyCKEditor.
+	 * @param {String} [data.editor] Path to the editor type e.g. `classic-editor/classic.js`.
+	 * @param {Array.<String>} [data.features] List of paths or names to features which need to be included in bundle.
+	 * @returns {String} Entry file content.
+	 */
+	renderEntryFileContent( dir, data ) {
+		const creatorName = utils.capitalize( path.basename( data.editor, '.js' ) );
+		const creatorPath = path.relative( dir, utils.getModuleFullPath( data.editor ) );
+		let featureNames = [];
+
+		// Returns a list of plugin imports.
+		function renderPluginImports( features = [] ) {
+			let templateFragment = '';
+
+			for ( let feature of features ) {
+				feature = utils.getPluginPath( feature );
+
+				const featurePath = path.relative( dir, feature );
+
+				// Generate unique feature name.
+				// In case of two ore more features will have the same name:
+				// 		features: [
+				// 			'typing',
+				// 			'path/to/other/plugin/typing'
+				// 		]
+				let featureName = utils.capitalize( path.basename( feature, '.js' ) );
+				let i = 0;
+
+				while ( featureNames.indexOf( featureName ) >= 0 ) {
+					featureName = utils.capitalize( path.basename( feature, `.js` ) ) + ( ++i ).toString();
+				}
+
+				templateFragment += `import ${ featureName } from '${ featurePath }';\n`;
+				featureNames.push( featureName );
+			}
+
+			return templateFragment;
+		}
+
+		return `
+'use strict';
+
+// Babel helpers.
+import '${ path.relative( dir, 'node_modules/regenerator-runtime/runtime.js' ) }';
+
+import ${ creatorName } from '${ creatorPath }';
+${ renderPluginImports( data.features ) }
+
+export default class ${ data.moduleName } extends ${ creatorName } {
+	static create( element, config = {} ) {
+		if ( !config.features ) {
+			config.features = [];
+		}
+
+		config.features = [ ...config.features, ${ featureNames.join( ', ' ) } ];
+
+		return ${ creatorName }.create( element, config );
+	}
+}
+`;
+	},
+
+	/**
+	 * Saves files from stream in specific destination and add `.min` suffix to the name.
+	 *
+	 * @param {Stream} stream Source stream.
+	 * @param {String} destination Path to the destination directory.
+	 * @returns {Stream}
+	 */
+	saveFileFromStreamAsMinified( stream, destination ) {
+		return stream
+			.pipe( gulpRename( {
+				suffix: '.min'
+			} ) )
+			.pipe( gulp.dest( destination ) );
+	},
+
+	/**
+	 * Copies specified file to specified destination.
+	 *
+	 * @param {String} from Source path.
+	 * @param {String} to Destination directory.
+	 * @returns {Promise}
+	 */
+	copyFile( from, to ) {
+		return new Promise( ( resolve ) => {
+			gulp.src( from )
+				.pipe( gulp.dest( to ) )
+				.on( 'finish', resolve );
+		} );
+	},
+
+	/**
+	 * Gets size of the file.
+	 *
+	 * @param {String} path Path to the file.
+	 * @returns {Number} Size in bytes.
+	 */
+	getFileSize( path ) {
+		return fs.statSync( path ).size;
+	},
+
+	/**
+	 * Gets human readable gzipped size of the file.
+	 *
+	 * @param {String} path Path to the file.
+	 * @returns {Number} Size in bytes.
+	 */
+	getGzippedFileSize( path ) {
+		return gzipSize.sync( fs.readFileSync( path ) );
+	},
+
+	/**
+	 * Gets normal and gzipped size of every passed file in specified directory.
+	 *
+	 * @param {Array.<String>} files List of file paths.
+	 * @param {String} [rootDir=''] When each file is in the same directory.
+	 * @returns {Array.<Object>} List with file size data.
+	 *
+	 * Objects contain the following fields:
+	 *
+	 * * name – File name.
+	 * * size – File size in human readable format.
+	 * * gzippedSize – Gzipped file size in human readable format.
+	 */
+	getFilesSizeStats( files, rootDir = '' ) {
+		return files.map( ( file ) => {
+			const filePath = path.join( rootDir, file );
+
+			return {
+				name: path.basename( filePath ),
+				size: utils.getFileSize( filePath ),
+				gzippedSize: utils.getGzippedFileSize( filePath )
+			};
+		} );
+	},
+
+	/**
+	 * Prints on console summary with a list of files with their size stats.
+	 *
+	 *		Title:
+	 *		file.js: 1 MB (gzipped: 400 kB)
+	 *		file.css 500 kB (gzipped: 100 kB)
+	 *
+	 * @param {String} title Summary title.
+	 * @param {Array.<Object>} filesStats
+	 */
+	showFilesSummary( title, filesStats ) {
+		const label = gutil.colors.underline( title );
+		const filesSummary = filesStats.map( ( file ) => {
+			return `${ file.name }: ${ prettyBytes( file.size ) } (gzipped: ${ prettyBytes( file.gzippedSize ) })`;
+		} ).join( '\n' );
+
+		gutil.log( gutil.colors.green( `\n${ label }:\n${ filesSummary }` ) );
+	}
+};
+
+// Extend top level utils.
+module.exports = Object.assign( utils, mainUtils );

+ 30 - 0
dev/tasks/utils.js

@@ -0,0 +1,30 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+const path = require( 'path' );
+const del = require( 'del' );
+const gutil = require( 'gulp-util' );
+
+const utils = {
+	/**
+	 * Removes files and directories specified by `glob` starting from `rootDir`
+	 * and gently informs about deletion.
+	 *
+	 * @param {String} rootDir The path to the root directory (i.e. "dist/").
+	 * @param {String} glob Glob specifying what to clean.
+	 * @returns {Promise}
+	 */
+	clean( rootDir, glob ) {
+		return del( path.join( rootDir, glob ) ).then( paths => {
+			paths.forEach( p => {
+				gutil.log( `Deleted file '${ gutil.colors.cyan( p ) }'.` );
+			} );
+		} );
+	}
+};
+
+module.exports = utils;

+ 5 - 0
dev/tests/build/utils.js

@@ -16,6 +16,7 @@ const path = require( 'path' );
 const stream = require( 'stream' );
 const Vinyl = require( 'vinyl' );
 const through = require( 'through2' );
+const mainUtils = require( '../../tasks/utils' );
 
 describe( 'build-utils', () => {
 	const utils = require( '../../tasks/build/utils' );
@@ -29,6 +30,10 @@ describe( 'build-utils', () => {
 		sandbox.restore();
 	} );
 
+	it( 'should be extended by top level utils', () => {
+		expect( utils.clean ).to.be.equal( mainUtils.clean );
+	} );
+
 	describe( 'noop', () => {
 		it( 'should return PassTrough stream', () => {
 			const PassThrough = stream.PassThrough;

+ 254 - 0
dev/tests/bundle/utils.js

@@ -0,0 +1,254 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global describe, it, beforeEach, afterEach */
+
+'use strict';
+
+const chai = require( 'chai' );
+const expect = chai.expect;
+const sinon = require( 'sinon' );
+const path = require( 'path' );
+const fs = require( 'fs' );
+const mainUtils = require( '../../tasks/utils' );
+const gzipSize = require( 'gzip-size' );
+
+describe( 'bundle-utils', () => {
+	const utils = require( '../../tasks/bundle/utils' );
+	let sandbox;
+
+	beforeEach( () => {
+		sandbox = sinon.sandbox.create();
+	} );
+
+	afterEach( () => {
+		sandbox.restore();
+	} );
+
+	it( 'should be extended by top level utils', () => {
+		expect( utils.clean ).to.equal( mainUtils.clean );
+	} );
+
+	describe( 'getModuleFullPath', () => {
+		it( 'should return full path when passed path not relative', () => {
+			expect( utils.getModuleFullPath( 'editor-classic/classic' ) ).to.equal( './build/esnext/ckeditor5/editor-classic/classic.js' );
+		} );
+
+		it( 'should return unmodified path when passed path is a relative', () => {
+			expect( utils.getModuleFullPath( './path/to/editor-classic/classic' ) ).to.equal( './path/to/editor-classic/classic.js' );
+			expect( utils.getModuleFullPath( '../path/to/editor-classic/classic' ) ).to.equal( '../path/to/editor-classic/classic.js' );
+		} );
+	} );
+
+	describe( 'getPluginPath()', () => {
+		it( 'should resolve a simple plugin name to the full path', () => {
+			expect( utils.getPluginPath( 'typing' ) ).to.equal( './build/esnext/ckeditor5/typing/typing.js' );
+		} );
+
+		it( 'should return full path if passed argument is a relative path', () => {
+			expect( utils.getPluginPath( 'typing/typing' ) ).to.equal( './build/esnext/ckeditor5/typing/typing.js' );
+		} );
+
+		it( 'should return unmodified plugin path if passed argument is a relative path', () => {
+			expect( utils.getPluginPath( './typing' ) ).to.equal( './typing.js' );
+			expect( utils.getPluginPath( '../typing' ) ).to.equal( '../typing.js' );
+		} );
+	} );
+
+	describe( 'capitalize()', () => {
+		it( 'should transform first letter of the passed string to uppercase', () => {
+			expect( utils.capitalize( 'string' ) ).to.equal( 'String' );
+			expect( utils.capitalize( 'multi word string' ) ).to.equal( 'Multi word string' );
+		} );
+	} );
+
+	describe( 'renderEntryFileContent()', () => {
+		it( 'should render file content with proper data', () => {
+			const result = utils.renderEntryFileContent( './bundle/tmp', {
+				moduleName: 'MyCKEditor',
+				editor: 'editor-classic/classic',
+				features: [
+					'delete',
+					'path/to/default',
+					'./path/to/custom'
+				]
+			} );
+
+			const expected = `
+'use strict';
+
+// Babel helpers.
+import '../../node_modules/regenerator-runtime/runtime.js';
+
+import Classic from '../../build/esnext/ckeditor5/editor-classic/classic.js';
+import Delete from '../../build/esnext/ckeditor5/delete/delete.js';
+import Default from '../../build/esnext/ckeditor5/path/to/default.js';
+import Custom from '../../path/to/custom.js';
+
+
+export default class MyCKEditor extends Classic {
+	static create( element, config = {} ) {
+		if ( !config.features ) {
+			config.features = [];
+		}
+
+		config.features = [ ...config.features, Delete, Default, Custom ];
+
+		return Classic.create( element, config );
+	}
+}
+`;
+
+			expect( result ).to.equal( expected );
+		} );
+
+		it( 'should render file content with unique plugin names', () => {
+			const result = utils.renderEntryFileContent( './bundle/tmp', {
+				moduleName: 'MyCKEditor',
+				editor: 'editor-classic/classic',
+				features: [
+					'plugin',
+					'path/to/plugin',
+					'other/path/to/plugin'
+				]
+			} );
+
+			const expected = `
+'use strict';
+
+// Babel helpers.
+import '../../node_modules/regenerator-runtime/runtime.js';
+
+import Classic from '../../build/esnext/ckeditor5/editor-classic/classic.js';
+import Plugin from '../../build/esnext/ckeditor5/plugin/plugin.js';
+import Plugin1 from '../../build/esnext/ckeditor5/path/to/plugin.js';
+import Plugin2 from '../../build/esnext/ckeditor5/other/path/to/plugin.js';
+
+
+export default class MyCKEditor extends Classic {
+	static create( element, config = {} ) {
+		if ( !config.features ) {
+			config.features = [];
+		}
+
+		config.features = [ ...config.features, Plugin, Plugin1, Plugin2 ];
+
+		return Classic.create( element, config );
+	}
+}
+`;
+
+			expect( result ).to.equal( expected );
+		} );
+
+		it( 'should render file content with proper without features', () => {
+			const result = utils.renderEntryFileContent( './bundle/tmp', {
+				moduleName: 'MyCKEditor',
+				editor: 'editor-classic/classic'
+			} );
+
+			const expected = `
+'use strict';
+
+// Babel helpers.
+import '../../node_modules/regenerator-runtime/runtime.js';
+
+import Classic from '../../build/esnext/ckeditor5/editor-classic/classic.js';
+
+
+export default class MyCKEditor extends Classic {
+	static create( element, config = {} ) {
+		if ( !config.features ) {
+			config.features = [];
+		}
+
+		config.features = [ ...config.features,  ];
+
+		return Classic.create( element, config );
+	}
+}
+`;
+
+			expect( result ).to.equal( expected );
+		} );
+	} );
+
+	describe( 'getFileSize', () => {
+		it( 'should return file size in bytes', () => {
+			const filePath = 'path/to/file';
+			const size = 1337;
+			const statSyncMock = sandbox.stub( fs, 'statSync', () => {
+				return { size };
+			} );
+
+			expect( utils.getFileSize( filePath ) ).to.be.equal( size );
+			sinon.assert.calledWithExactly( statSyncMock, filePath );
+		} );
+	} );
+
+	describe( 'getGzippedFileSize', () => {
+		it( 'should return file size in bytes', () => {
+			const filePath = 'path/to/file';
+			const size = 1337;
+			const fileContent = 'some string';
+			const readFileSyncMock = sandbox.stub( fs, 'readFileSync', () => fileContent );
+			const gzipSizeMock = sandbox.stub( gzipSize, 'sync', () => 1337 );
+
+			expect( utils.getGzippedFileSize( filePath ) ).to.be.equal( size );
+			sinon.assert.calledWithExactly( readFileSyncMock, filePath );
+			sinon.assert.calledWithExactly( gzipSizeMock, fileContent );
+		} );
+	} );
+
+	describe( 'getFilesSizeStats', () => {
+		let size, gzippedSize;
+
+		beforeEach( () => {
+			size = 1337;
+			gzippedSize = 543;
+
+			sandbox.stub( utils, 'getFileSize', () => size );
+			sandbox.stub( utils, 'getGzippedFileSize', () => gzippedSize );
+		} );
+
+		it( 'should returns an array with two elements', () => {
+			const result = utils.getFilesSizeStats( [ 'sub/dir/file.js', 'other/sub/dir/file.css' ], 'root/path' );
+
+			expect( result ).to.be.an( 'array' );
+			expect( result ).to.have.length( 2 );
+		} );
+
+		it( 'should returns list of object with files stats', () => {
+			const result = utils.getFilesSizeStats( [ 'sub/dir/file.js', 'other/sub/dir/file.css' ], 'root/path' );
+
+			expect( result ).to.be.deep.equal( [
+				{ name: 'file.js', size, gzippedSize },
+				{ name: 'file.css', size, gzippedSize }
+			] );
+		} );
+
+		it( 'should get files from root directory', () => {
+			let basenameSpy = sandbox.spy( path, 'basename' );
+
+			const result = utils.getFilesSizeStats( [ 'sub/dir/file.js', 'other/sub/dir/file.css' ], 'root/path' );
+
+			expect( result[ 0 ] ).to.have.property( 'name', 'file.js' );
+			expect( result[ 1 ] ).to.have.property( 'name', 'file.css' );
+			sinon.assert.calledWithExactly( basenameSpy.firstCall, 'root/path/sub/dir/file.js' );
+			sinon.assert.calledWithExactly( basenameSpy.secondCall, 'root/path/other/sub/dir/file.css' );
+		} );
+
+		it( 'should get files if root directory is not specified', () => {
+			let basenameSpy = sandbox.spy( path, 'basename' );
+
+			const result = utils.getFilesSizeStats( [ 'sub/dir/file.js', 'file.css' ] );
+
+			expect( result[ 0 ] ).to.have.property( 'name', 'file.js' );
+			expect( result[ 1 ] ).to.have.property( 'name', 'file.css' );
+			sinon.assert.calledWithExactly( basenameSpy.firstCall, 'sub/dir/file.js' );
+			sinon.assert.calledWithExactly( basenameSpy.secondCall, 'file.css' );
+		} );
+	} );
+} );

+ 2 - 0
gulpfile.js

@@ -7,6 +7,7 @@ const gulp = require( 'gulp' );
 const config = {
 	ROOT_DIR: '.',
 	BUILD_DIR: 'build',
+	BUNDLE_DIR: 'bundle',
 	WORKSPACE_DIR: '..',
 
 	// Files ignored by jshint and jscs tasks. Files from .gitignore will be added automatically during tasks execution.
@@ -16,6 +17,7 @@ const config = {
 };
 
 require( './dev/tasks/build/tasks' )( config ).register();
+require( './dev/tasks/bundle/tasks' )( config ).register();
 require( './dev/tasks/dev/tasks' )( config ).register();
 require( './dev/tasks/lint/tasks' )( config ).register();
 require( './dev/tasks/test/tasks' )( config ).register();

+ 9 - 0
package.json

@@ -28,6 +28,7 @@
     "babel-core": "^6.9.0",
     "babel-plugin-transform-es2015-modules-amd": "^6.8.0",
     "babel-plugin-transform-es2015-modules-commonjs": "^6.10.0",
+    "babel-preset-es2015-rollup": "^1.1.1",
     "benderjs": "^0.4.1",
     "benderjs-chai": "^0.2.0",
     "benderjs-coverage": "benderjs/benderjs-coverage#t/4",
@@ -41,6 +42,7 @@
     "git-guppy": "^1.1.0",
     "gulp": "^3.9.0",
     "gulp-babel": "^6.1.0",
+    "gulp-cssnano": "^2.1.2",
     "gulp-filter": "^3.0.1",
     "gulp-filter-by": "^1.2.0",
     "gulp-istanbul": "^0.10.4",
@@ -54,21 +56,28 @@
     "gulp-replace": "^0.5.4",
     "gulp-sourcemaps": "^1.6.0",
     "gulp-svg-sprite": "^1.2.19",
+    "gulp-uglify": "^1.5.4",
     "gulp-util": "^3.0.7",
     "gulp-watch": "^4.3.7",
     "guppy-pre-commit": "^0.3.0",
+    "gzip-size": "^3.0.0",
     "inquirer": "^0.11.0",
     "jsdoc": "^3.4.0",
     "jshint": "^2.9.1",
     "jshint-reporter-jscs": "^0.1.0",
     "merge-stream": "^1.0.0",
     "minimist": "^1.2.0",
+    "mkdirp": "^0.5.1",
     "mockery": "^1.4.0",
     "multipipe": "^0.3.0",
     "ncp": "^2.0.0",
     "node-sass": "^3.7.0",
     "parse-gitignore": "^0.2.0",
+    "pretty-bytes": "^3.0.1",
+    "regenerator-runtime": "^0.9.5",
     "replace": "^0.3.0",
+    "rollup": "^0.33.0",
+    "rollup-plugin-babel": "^2.4.0",
     "run-sequence": "^1.1.5",
     "semver": "^5.1.0",
     "shelljs": "^0",