8
0
Просмотр исходного кода

Implemented build tasks for themes and icons.

Aleksander Nowodzinski 10 лет назад
Родитель
Сommit
3c8436e7b9
6 измененных файлов с 655 добавлено и 182 удалено
  1. 18 0
      dev/tasks/build/icons-template.js
  2. 272 175
      dev/tasks/build/tasks.js
  3. 200 1
      dev/tasks/build/utils.js
  4. 6 6
      dev/tests/build/tasks.js
  5. 154 0
      dev/tests/build/utils.js
  6. 5 0
      package.json

+ 18 - 0
dev/tasks/build/icons-template.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+export const sprite = `{{#shapes}}
+{{{svg}}}{{/shapes}}`;
+// var tmp = document.createElement( 'div' );
+
+// 	tmp.innerHTML =
+// 		'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="ck-sprite">' +
+// 			sprite +
+// 		'</svg>';
+
+// 	document.body.appendChild( tmp.firstChild );
+// } );

+ 272 - 175
dev/tasks/build/tasks.js

@@ -5,239 +5,336 @@
 
 'use strict';
 
-const fs = require( 'fs' );
 const path = require( 'path' );
 const gulp = require( 'gulp' );
-const del = require( 'del' );
 const merge = require( 'merge-stream' );
 const gulpMirror = require( 'gulp-mirror' );
 const gulpWatch = require( 'gulp-watch' );
+const gulpPlumber = require( 'gulp-plumber' );
 const gutil = require( 'gulp-util' );
-const minimist = require( 'minimist' );
 const utils = require( './utils' );
+const runSequence = require( 'run-sequence' );
 
 module.exports = ( config ) => {
 	const buildDir = path.join( config.ROOT_DIR, config.BUILD_DIR );
+	const themesGlob = path.join( 'theme', '**', '*.scss' );
+	const iconsGlob = path.join( 'theme', 'icons', '*.svg' );
 
 	const tasks = {
-		/**
-		 * Removes the build directory.
-		 */
-		clean() {
-			return del( buildDir );
-		},
-
-		src: {
+		clean: {
 			/**
-			 * Returns a stream of all source files.
-			 *
-			 * @param {Boolean} [watch] Whether the files should be watched.
-			 * @returns {Stream}
+			 * Removes "themes" folder from "./build/{format}" directory.
 			 */
-			all( watch ) {
-				return merge( tasks.src.main( watch ), tasks.src.ckeditor5( watch ), tasks.src.packages( watch ) );
+			themes() {
+				return utils.clean( buildDir, path.join( `@(${ utils.parseArguments().formats.join( '|' ) })`, 'theme' ) );
 			},
 
 			/**
-			 * Returns a stream with just the main file (`ckeditor5/ckeditor.js`).
-			 *
-			 * @param {Boolean} [watch] Whether to watch the files.
-			 * @returns {Stream}
+			 * Removes all but "themes" folder from "./build/{format}" directory.
 			 */
-			main( watch ) {
-				const glob = path.join( config.ROOT_DIR, 'ckeditor.js' );
-
-				return gulp.src( glob )
-					.pipe( watch ? gulpWatch( glob ) : utils.noop() );
+			js() {
+				return utils.clean( buildDir, path.join( `@(${ utils.parseArguments().formats.join( '|' ) })`, '!(theme)' ) );
 			},
 
 			/**
-			 * Returns a stream of all source files from CKEditor 5.
-			 *
-			 * @param {Boolean} [watch] Whether to watch the files.
-			 * @returns {Stream}
+			 * Removes the "./build" directory.
 			 */
-			ckeditor5( watch ) {
-				const glob = path.join( config.ROOT_DIR, '@(src|tests)', '**', '*' );
+			all() {
+				return utils.clean( buildDir, path.join() );
+			}
+		},
 
-				return gulp.src( glob, { nodir: true } )
-					.pipe( watch ? gulpWatch( glob ) : utils.noop() )
-					.pipe( utils.renameCKEditor5Files() );
+		src: {
+			js: {
+				/**
+				 * Returns a stream of all source files.
+				 *
+				 * @param {Boolean} [watch] Whether the files should be watched.
+				 * @returns {Stream}
+				 */
+				all( watch ) {
+					return merge( tasks.src.js.main( watch ), tasks.src.js.ckeditor5( watch ), tasks.src.js.packages( watch ) );
+				},
+
+				/**
+				 * Returns a stream with just the main file (`ckeditor5/ckeditor.js`).
+				 *
+				 * @param {Boolean} [watch] Whether to watch the files.
+				 * @returns {Stream}
+				 */
+				main( watch ) {
+					const glob = path.join( config.ROOT_DIR, 'ckeditor.js' );
+
+					return gulp.src( glob )
+						.pipe( watch ? gulpWatch( glob ) : utils.noop() );
+				},
+
+				/**
+				 * Returns a stream of all source files from CKEditor 5.
+				 *
+				 * @param {Boolean} [watch] Whether to watch the files.
+				 * @returns {Stream}
+				 */
+				ckeditor5( watch ) {
+					const glob = path.join( config.ROOT_DIR, '@(src|tests)', '**', '*' );
+
+					return gulp.src( glob, { nodir: true } )
+						.pipe( watch ? gulpWatch( glob ) : utils.noop() )
+						.pipe( utils.renameCKEditor5Files() );
+				},
+
+				/**
+				 * Returns a stream of all source files from CKEditor 5 dependencies.
+				 *
+				 * @param {Boolean} [watch] Whether to watch the files.
+				 * @returns {Stream}
+				 */
+				packages( watch ) {
+					const dirs = utils.getPackages( config.ROOT_DIR );
+
+					const streams = dirs.map( ( dirPath ) => {
+						const glob = path.join( dirPath, '@(src|tests)', '**', '*' );
+						// Use parent as a base so we get paths starting with 'ckeditor5-*/src/*' in the stream.
+						const baseDir = path.parse( dirPath ).dir;
+						const opts = { base: baseDir, nodir: true };
+
+						return gulp.src( glob, opts )
+							.pipe( watch ? gulpWatch( glob, opts ) : utils.noop() );
+					} );
+
+					return merge.apply( null, streams )
+						.pipe( utils.renamePackageFiles() );
+				}
 			},
 
 			/**
-			 * Returns a stream of all source files from CKEditor 5 dependencies.
+			 * Returns a stream of all theme (*.scss) files.
 			 *
-			 * @param {Boolean} [watch] Whether to watch the files.
 			 * @returns {Stream}
 			 */
-			packages( watch ) {
-				// Find all CKEditor5 package directories. Resolve symlinks so we watch real directories
-				// in order to workaround https://github.com/paulmillr/chokidar/issues/419.
-				const dirs = fs.readdirSync( path.join( config.ROOT_DIR, 'node_modules' ) )
-					// Look for ckeditor5-* directories.
-					.filter( ( fileName ) => fileName.indexOf( 'ckeditor5-' ) === 0 )
-					// Resolve symlinks and keep only directories.
-					.map( ( fileName ) => {
-						let filePath = path.join( config.ROOT_DIR, 'node_modules', fileName );
-						let stat = fs.lstatSync( filePath );
-
-						if ( stat.isSymbolicLink() ) {
-							filePath = fs.realpathSync( filePath );
-							stat = fs.lstatSync( filePath );
-						}
-
-						if ( stat.isDirectory() ) {
-							return filePath;
-						}
-
-						// Filter...
-						return false;
-					} )
-					// 					...those out.
-					.filter( ( filePath ) => filePath );
+			sass() {
+				const dirs = utils.getPackages( config.ROOT_DIR );
+
+				const streams = dirs.map( ( dirPath ) => {
+					const glob = path.join( dirPath, themesGlob );
+					const baseDir = path.parse( dirPath ).dir;
+					const opts = { base: baseDir, nodir: true };
+
+					return gulp.src( glob, opts );
+				} );
+
+				return merge.apply( null, streams );
+			},
+
+			icons() {
+				const dirs = utils.getPackages( config.ROOT_DIR );
 
 				const streams = dirs.map( ( dirPath ) => {
-					const glob = path.join( dirPath, '@(src|tests)', '**', '*' );
-					// Use parent as a base so we get paths starting with 'ckeditor5-*/src/*' in the stream.
+					const glob = path.join( dirPath, iconsGlob );
 					const baseDir = path.parse( dirPath ).dir;
 					const opts = { base: baseDir, nodir: true };
 
-					return gulp.src( glob, opts )
-						.pipe( watch ? gulpWatch( glob, opts ) : utils.noop() );
+					return gulp.src( glob, opts );
 				} );
 
-				return merge.apply( null, streams )
-					.pipe( utils.renamePackageFiles() );
+				return merge.apply( null, streams );
 			}
 		},
 
-		/**
-		 * The main build task which is capable of copying, watching, processing and writing all files
-		 * to the `build/` directory.
-		 *
-		 * @param {Object} options
-		 * @param {String} options.formats
-		 * @param {Boolean} [options.watch]
-		 * @returns {Stream}
-		 */
-		build( options ) {
-			//
-			// NOTE: Error handling in streams is hard.
-			//
-			// Most likely this code isn't optimal, but it's a result of 8h spent on search
-			// for a solution to the ruined pipeline whenever something throws.
-			//
-			// Most important fact is that when dest stream emits an error the src stream
-			// unpipes it. Problem is when you start using tools like multipipe or gulp-mirror,
-			// because you lose control over the graph of the streams and you cannot reconnect them
-			// with a full certainty that you connected them correctly, since you'd need to know these
-			// libs internals.
-			//
-			// BTW. No, gulp-plumber is not a solution here because it does not affect the other tools.
-			//
-			// Hence, I decided that it'll be best to restart the whole piece. However, I wanted to avoid restarting the
-			// watcher as it sounds like something heavy.
-			//
-			// The flow looks like follows:
-			//
-			// 1. codeStream (including logger)
-			// 2. inputStream
-			// 3. conversionStream (may throw)
-			// 4. outputStream
-			//
-			// The input and output streams allowed me to easier debug and restart everything. Additionally, the output
-			// stream is returned to Gulp so it must be stable. I decided to restart also the inputStream because when conversionStream
-			// throws, then inputStream gets paused. Most likely it's possible to resume it, so we could pipe codeStream directly to
-			// conversionStream, but it was easier this way.
-			//
-			// PS. The assumption is that all errors thrown somewhere inside conversionStream are forwarded to conversionStream.
-			// Multipipe and gulp-mirror seem to work this way, so we get a single error emitter.
-			const formats = options.formats.split( ',' );
-			const codeStream = tasks.src.all( options.watch )
-				.pipe(
-					utils.noop( ( file ) => {
-						gutil.log( `Processing '${ gutil.colors.cyan( file.path ) }'...` );
-					} )
-				);
-			const conversionStreamGenerator = utils.getConversionStreamGenerator( buildDir );
-			const outputStream = utils.noop();
-
-			let inputStream;
-			let conversionStream;
-
-			startStreams();
-
-			return outputStream;
-
-			// Creates a single stream combining multiple conversion streams.
-			function createConversionStream() {
-				const formatPipes = formats.reduce( conversionStreamGenerator, [] );
-
-				return gulpMirror.apply( null, formatPipes )
-					.on( 'error', onError );
-			}
+		build: {
+			/**
+			 * The build task which is capable of copying, watching, processing and writing all JavaScript files
+			 * to the `build/` directory.
+			 *
+			 * @param {Object} options
+			 * @param {String} options.formats
+			 * @param {Boolean} [options.watch]
+			 * @returns {Stream}
+			 */
+			js( options ) {
+				//
+				// NOTE: Error handling in streams is hard.
+				//
+				// Most likely this code isn't optimal, but it's a result of 8h spent on search
+				// for a solution to the ruined pipeline whenever something throws.
+				//
+				// Most important fact is that when dest stream emits an error the src stream
+				// unpipes it. Problem is when you start using tools like multipipe or gulp-mirror,
+				// because you lose control over the graph of the streams and you cannot reconnect them
+				// with a full certainty that you connected them correctly, since you'd need to know these
+				// libs internals.
+				//
+				// BTW. No, gulp-plumber is not a solution here because it does not affect the other tools.
+				//
+				// Hence, I decided that it'll be best to restart the whole piece. However, I wanted to avoid restarting the
+				// watcher as it sounds like something heavy.
+				//
+				// The flow looks like follows:
+				//
+				// 1. codeStream (including logger)
+				// 2. inputStream
+				// 3. conversionStream (may throw)
+				// 4. outputStream
+				//
+				// The input and output streams allowed me to easier debug and restart everything. Additionally, the output
+				// stream is returned to Gulp so it must be stable. I decided to restart also the inputStream because when conversionStream
+				// throws, then inputStream gets paused. Most likely it's possible to resume it, so we could pipe codeStream directly to
+				// conversionStream, but it was easier this way.
+				//
+				// PS. The assumption is that all errors thrown somewhere inside conversionStream are forwarded to conversionStream.
+				// Multipipe and gulp-mirror seem to work this way, so we get a single error emitter.
+				const codeStream = tasks.src.js.all( options.watch )
+					.pipe(
+						utils.noop( ( file ) => {
+							gutil.log( `Processing '${ gutil.colors.cyan( file.path ) }'...` );
+						} )
+					);
+				const conversionStreamGenerator = utils.getConversionStreamGenerator( buildDir );
+				const outputStream = utils.noop();
+
+				let inputStream;
+				let conversionStream;
 
-			// Handles error in the combined conversion stream.
-			// If we don't watch files, make sure that the process terminates ASAP. We could forward the error
-			// to the output, but there may be some data in the pipeline and our error could be covered
-			// by dozen of other messages.
-			// If we watch files, then clean up the old streams and restart the combined conversion stream.
-			function onError() {
-				if ( !options.watch ) {
-					process.exit( 1 );
+				startStreams();
+
+				return outputStream;
 
-					return;
+				// Creates a single stream combining multiple conversion streams.
+				function createConversionStream() {
+					const formatPipes = options.formats.reduce( conversionStreamGenerator, [] );
+
+					return gulpMirror.apply( null, formatPipes )
+						.on( 'error', onError );
 				}
 
-				unpipeStreams();
+				// Handles error in the combined conversion stream.
+				// If we don't watch files, make sure that the process terminates ASAP. We could forward the error
+				// to the output, but there may be some data in the pipeline and our error could be covered
+				// by dozen of other messages.
+				// If we watch files, then clean up the old streams and restart the combined conversion stream.
+				function onError() {
+					if ( !options.watch ) {
+						process.exit( 1 );
 
-				gutil.log( 'Restarting...' );
-				startStreams();
-			}
+						return;
+					}
 
-			function startStreams() {
-				inputStream = utils.noop();
-				conversionStream = createConversionStream();
+					unpipeStreams();
 
-				codeStream
-					.pipe( inputStream )
-					.pipe( conversionStream )
-					.pipe( outputStream );
-			}
+					gutil.log( 'Restarting...' );
+					startStreams();
+				}
+
+				function startStreams() {
+					inputStream = utils.noop();
+					conversionStream = createConversionStream();
+
+					codeStream
+						.pipe( inputStream )
+						.pipe( conversionStream )
+						.pipe( outputStream );
+				}
+
+				function unpipeStreams() {
+					codeStream.unpipe( inputStream );
+					conversionStream.unpipe( outputStream );
+				}
+			},
+
+			/**
+			 * The task capable of watching, processing and writing CSS files into
+			 * the `build/{format}/theme` directory.
+			 *
+			 * @param {Object} options
+			 * @param {String} options.formats
+			 * @param {Boolean} [options.watch]
+			 * @returns {Stream}
+			 */
+			sass( options ) {
+				if ( options.watch ) {
+					const glob = path.join( config.ROOT_DIR, 'node_modules', 'ckeditor5-*', themesGlob );
+
+					// Initial build.
+					build();
+
+					gutil.log( `Watching theme files in '${ gutil.colors.cyan( glob ) }' for changes...` );
 
-			function unpipeStreams() {
-				codeStream.unpipe( inputStream );
-				conversionStream.unpipe( outputStream );
+					return gulp.watch( glob, event => {
+						gutil.log( `Theme file '${ gutil.colors.cyan( event.path ) }' has been ${ event.type }...` );
+
+						// Re-build the entire theme if the file has been changed.
+						return build();
+					} );
+				} else {
+					return build();
+				}
+
+				function build() {
+					const dests = utils.getThemeDestsForFormats( buildDir, options.formats );
+
+					return tasks.src.sass()
+						.pipe( gulpPlumber() )
+						.pipe( utils.filterThemeEntryPoints() )
+						.pipe( utils.compileThemes( 'ckeditor.css' ) )
+						.pipe( gulpMirror( dests ) )
+						.pipe(
+							utils.noop( ( file ) => {
+								gutil.log( `Output file saved to '${ gutil.colors.cyan( file.path ) }'.` );
+							} )
+						)
+						.on( 'error', console.log );
+				}
+			},
+
+			/**
+			 * The task capable of converting SVG files into `build/{format}/theme/icons.js`
+			 * sprite.
+			 *
+			 * @param {Object} options
+			 * @param {String} options.formats
+			 * @returns {Stream}
+			 */
+			icons( options ) {
+				const dests = utils.getThemeDestsForFormats( buildDir, options.formats );
+
+				return tasks.src.icons()
+					.pipe( utils.compileIconSprite() )
+					.pipe( gulpMirror( dests ) )
+					.pipe(
+						utils.noop( ( file ) => {
+							gutil.log( `Output file saved to '${ gutil.colors.cyan( file.path ) }'.` );
+						} )
+					);
 			}
 		}
 	};
 
-	gulp.task( 'build:clean', tasks.clean );
+	gulp.task( 'build', callback => {
+		runSequence( 'build:clean:all', 'build:themes', 'build:js', callback );
+	} );
 
-	gulp.task( 'build', [ 'build:clean' ], () => {
-		const knownOptions = {
-			string: [
-				'formats'
-			],
+	gulp.task( 'build:clean:all', tasks.clean.all );
+	gulp.task( 'build:clean:themes', tasks.clean.themes );
+	gulp.task( 'build:clean:js', tasks.clean.js );
 
-			boolean: [
-				'watch'
-			],
+	gulp.task( 'build:themes', ( callback ) => {
+		runSequence( 'build:clean:themes', 'build:icons', 'build:sass', callback );
+	} );
 
-			default: {
-				formats: 'amd',
-				watch: false
-			}
-		};
+	gulp.task( 'build:sass', () => {
+		return tasks.build.sass( utils.parseArguments() );
+	} );
 
-		const options = minimist( process.argv.slice( 2 ), knownOptions );
+	gulp.task( 'build:icons', () => {
+		return tasks.build.icons( utils.parseArguments() );
+	} );
 
-		return tasks.build( options );
+	gulp.task( 'build:js', [ 'build:clean:js' ], () => {
+		return tasks.build.js( utils.parseArguments() );
 	} );
 
-	gulp.task( 'build-esnext', [ 'build:clean' ], () => {
-		return tasks.build( { formats: 'esnext' } );
+	gulp.task( 'build:js:esnext', [ 'build:clean:js' ], () => {
+		return tasks.build.js( { formats: [ 'esnext' ] } );
 	} );
 
 	return tasks;

+ 200 - 1
dev/tasks/build/utils.js

@@ -15,6 +15,12 @@ const multipipe = require( 'multipipe' );
 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 utils = {
 	/**
 	 * Code which can be appended to a transpiled (into AMD) test files in order to
@@ -271,8 +277,13 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 
 				// And prepend 'tests/'.
 				dirFrags.unshift( 'tests' );
+			} else if ( firstFrag == 'theme' ) {
+				// Remove 'theme/' from the package dir.
+				// console.log( dirFrags );
+				// dirFrags.length = 0;
+				// dirFrags.splice( 1, 2 );
 			} else {
-				throw new Error( 'Path should start with "ckeditor5-*/(src|tests)".' );
+				throw new Error( 'Path should start with "ckeditor5-*/(src|tests|theme)".' );
 			}
 
 			file.dirname = path.join.apply( null, dirFrags );
@@ -359,6 +370,194 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 	 */
 	isJSFile( file ) {
 		return file.path.endsWith( '.js' );
+	},
+
+	/**
+	 * Finds all CKEditor5 package directories in "node_modules" folder.
+	 *
+	 * @param {String} rootDir A root directory containing "node_modules" folder.
+	 * @returns {Array} Array of ckeditor5-* package directory paths.
+	 */
+	getPackages( rootDir ) {
+		// Find all CKEditor5 package directories. Resolve symlinks so we watch real directories
+		// in order to workaround https://github.com/paulmillr/chokidar/issues/419.
+		return fs.readdirSync( path.join( rootDir, 'node_modules' ) )
+			// Look for ckeditor5-* directories.
+			.filter( ( fileName ) => fileName.indexOf( 'ckeditor5-' ) === 0 )
+			// Resolve symlinks and keep only directories.
+			.map( ( fileName ) => {
+				let filePath = path.join( rootDir, 'node_modules', fileName );
+				let stat = fs.lstatSync( filePath );
+
+				if ( stat.isSymbolicLink() ) {
+					filePath = fs.realpathSync( filePath );
+					stat = fs.lstatSync( filePath );
+				}
+
+				if ( stat.isDirectory() ) {
+					return filePath;
+				}
+
+				// Filter...
+				return false;
+			} )
+			// 					...those out.
+			.filter( ( filePath ) => filePath );
+	},
+
+	/**
+	 * Given the input stream of .scss files, this method finds entry-point
+	 * files (theme.scss) and returns them as a stream.
+	 *
+	 * @returns {Stream}
+	 */
+	filterThemeEntryPoints() {
+		return through.obj( function( file, enc, callback ) {
+			if ( file.path.match( /\/theme\.scss$/ ) ) {
+				gutil.log( `Found theme entry point '${ gutil.colors.cyan( file.path ) }'...` );
+				this.push( file );
+			}
+
+			callback();
+		} );
+	},
+
+	/**
+	 * Given the input stream of theme entry-point files (theme.scss), this method:
+	 * 	1. Collects paths to entry-point.
+	 * 	2. Builds the output CSS theme file using aggregated entry-points.
+	 * 	3. Returns a stream containing built CSS theme file.
+	 *
+	 * @param {String} fileName The name of the output CSS theme file.
+	 * @returns {Stream}
+	 */
+	compileThemes( fileName ) {
+		const paths = [];
+		const stream = through.obj( collectThemeEntryPoint, renderThemeFromEntryPoints );
+
+		function collectThemeEntryPoint( file, enc, callback ) {
+			paths.push( file.path );
+
+			callback();
+		}
+
+		function renderThemeFromEntryPoints( callback ) {
+			gutil.log( `Compiling theme from entry points into '${ gutil.colors.cyan( fileName ) }'...` );
+
+			const dataToRender = paths.map( p => `@import '${ p }';` )
+				.join( '\n' );
+
+			try {
+				const rendered = sass.renderSync( utils.getSassOptions( dataToRender ) );
+
+				stream.push( new gutil.File( {
+					path: fileName,
+					contents: new Buffer( rendered.css )
+				} ) );
+
+				callback();
+			} catch ( err ) {
+				callback( err );
+			}
+		}
+
+		return stream;
+	},
+
+	/**
+	 * 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
+	 * @returns {Array} [options.formats] Array of specified output formats ("esnext" or "amd").
+	 * @returns {Boolean} [options.watch] A flag which enables watch mode.
+	 */
+	parseArguments() {
+		const options = minimist( process.argv.slice( 2 ), {
+			string: [
+				'formats'
+			],
+
+			boolean: [
+				'watch'
+			],
+
+			default: {
+				formats: 'amd',
+				watch: false
+			}
+		} );
+
+		options.formats = options.formats.split( ',' );
+
+		return options;
+	},
+
+	/**
+	 * Parses command line arguments and returns them as a user-friendly hash.
+	 *
+	 * @param {String} dataToRender
+	 * @returns {Object}
+	 */
+	getSassOptions( dataToRender ) {
+		return {
+			data: dataToRender,
+			sourceMap: true,
+			sourceMapEmbed: true,
+			outputStyle: 'expanded',
+			sourceComments: true
+		};
+	},
+
+	compileIconSprite() {
+		return sprite( utils.getIconSpriteOptions() );
+	},
+
+	getIconSpriteOptions() {
+		return {
+			shape: {
+				id: {
+					generator: name => `ck-icon-${ name.match( /([^\/]*)\.svg$/ )[ 1 ] }`
+				},
+			},
+			svg: {
+				xmlDeclaration: false,
+				doctypeDeclaration: false,
+			},
+			mode: {
+				symbol: {
+					dest: '.', // Flatten symbol/
+					inline: true,
+					render: {
+						js: {
+							template: path.join( __dirname, 'icons-template.js' ),
+							dest: 'icons.js',
+						}
+					}
+				}
+			}
+		};
+	},
+
+	getThemeDestsForFormats( distDir, formats ) {
+		return formats.map( f => {
+			return gulp.dest( path.join( distDir, f, 'theme' ) );
+		} );
 	}
 };
 

+ 6 - 6
dev/tests/build/tasks.js

@@ -64,7 +64,7 @@ describe( 'build-tasks', () => {
 				};
 			} );
 			const gulpSrcSpy = sandbox.spy( gulp, 'src' );
-			tasks.src.packages( false );
+			tasks.src.js.packages( false );
 
 			sinon.assert.calledOnce( readDirStub );
 			sinon.assert.calledTwice( gulpSrcSpy );
@@ -89,7 +89,7 @@ describe( 'build-tasks', () => {
 			} );
 			const realPathStub = sandbox.stub( fs, 'realpathSync', () => '/real/path' );
 			const gulpSrcSpy = sandbox.spy( gulp, 'src' );
-			tasks.src.packages( false );
+			tasks.src.js.packages( false );
 
 			sinon.assert.calledOnce( readDirStub );
 			sinon.assert.calledOnce( realPathStub );
@@ -98,12 +98,12 @@ describe( 'build-tasks', () => {
 		} );
 	} );
 
-	describe( 'build', () => {
+	describe( 'build.js', () => {
 		it( 'should return build stream', ( done ) => {
 			const code = 'export default {};';
 			sandbox.stub( gutil, 'log' );
 
-			const build = tasks.build;
+			const build = tasks.build.js;
 			const stream = require( 'stream' );
 			const files = [
 				new Vinyl( {
@@ -116,7 +116,7 @@ describe( 'build-tasks', () => {
 			let written = 0;
 
 			// Stub input stream.
-			sandbox.stub( tasks.src, 'all', () => {
+			sandbox.stub( tasks.src.js, 'all', () => {
 				const fakeInputStream = new stream.Readable( { objectMode: true } );
 				fakeInputStream._read = () => {
 					fakeInputStream.push( files.pop() || null );
@@ -138,7 +138,7 @@ describe( 'build-tasks', () => {
 				} );
 			} );
 
-			const conversionStream = build( { formats: 'amd' } );
+			const conversionStream = build( { formats: [ 'amd' ] } );
 
 			conversionStream.on( 'finish', () => {
 				expect( written ).to.equal( 1 );

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

@@ -528,4 +528,158 @@ describe( 'build-utils', () => {
 		test( 'foo/tests/file.js', false );
 		test( 'tests/_foo/file.js', false );
 	} );
+
+	describe( 'getPackages', () => {
+		it( 'returns collected paths to ckeditor5-* packages', ( done ) => {
+			const fs = require( 'fs' );
+			const readDirStub = sandbox.stub( fs, 'readdirSync', () => {
+				return [
+					'ckeditor5-core',
+					'ckeditor5-theme-default'
+				];
+			} );
+			const statStub = sandbox.stub( fs, 'lstatSync', () => {
+				return {
+					isDirectory() {
+						return true;
+					},
+					isSymbolicLink() {
+						return false;
+					}
+				};
+			} );
+
+			expect( utils.getPackages( '.' ) ).to.have.members( [
+				'node_modules/ckeditor5-core',
+				'node_modules/ckeditor5-theme-default'
+			] );
+
+			sinon.assert.calledOnce( readDirStub );
+			sinon.assert.calledTwice( statStub );
+
+			done();
+		} );
+	} );
+
+	describe( 'filterThemeEntryPoints', () => {
+		it( 'returns a stream containing theme entry points', ( done ) => {
+			const stream = require( 'stream' );
+			const entryPoints = [];
+
+			function fakeInputStream() {
+				const files = [
+					new Vinyl( {
+						cwd: './',
+						path: 'foo/bar/_helper.scss',
+						contents: new Buffer( '' )
+					} ),
+					new Vinyl( {
+						cwd: './',
+						path: 'foo/bar/component.scss',
+						contents: new Buffer( '' )
+					} ),
+					new Vinyl( {
+						cwd: './',
+						path: 'foo/bar/theme.scss',
+						contents: new Buffer( '' )
+					} ),
+					new Vinyl( {
+						cwd: './',
+						path: 'foo/bar/_theme.scss',
+						contents: new Buffer( '' )
+					} )
+				];
+
+				const fake = new stream.Readable( { objectMode: true } );
+
+				fake._read = () => {
+					fake.push( files.pop() || null );
+				};
+
+				return fake;
+			}
+
+			fakeInputStream()
+				.pipe( utils.filterThemeEntryPoints() )
+				.pipe( through.obj( ( file, encoding, callback ) => {
+					entryPoints.push( file.path );
+
+					callback();
+				}, () => {
+					expect( entryPoints ).to.have.members( [ 'foo/bar/theme.scss' ] );
+
+					done();
+				} ) );
+		} );
+	} );
+
+	describe( 'compileThemes', () => {
+		it( 'returns a stream containing compiled CSS file', ( done ) => {
+			const stream = require( 'stream' );
+			let compiledThemePath;
+			let compiledThemeCss;
+
+			function fakeInputStream() {
+				const files = [
+					new Vinyl( {
+						cwd: './',
+						path: 'foo/bar/theme.scss',
+						contents: new Buffer( '' )
+					} ),
+					new Vinyl( {
+						cwd: './',
+						path: 'baz/qux/theme.scss',
+						contents: new Buffer( '' )
+					} ),
+				];
+
+				const fake = new stream.Readable( { objectMode: true } );
+
+				fake._read = () => {
+					fake.push( files.pop() || null );
+				};
+
+				return fake;
+			}
+
+			sandbox.stub( utils, 'getSassOptions', dataToRender => {
+				return {
+					data: dataToRender,
+					outputStyle: 'expanded',
+					importer: url => {
+						return { file: url, contents: `/*! ${ url } */` };
+					}
+				};
+			} );
+
+			fakeInputStream()
+				.pipe( utils.compileThemes( 'abc.css' ) )
+				.pipe( through.obj( ( file, encoding, callback ) => {
+					compiledThemePath = file.path;
+					compiledThemeCss = file.contents;
+
+					callback();
+				}, () => {
+					expect( compiledThemePath ).to.be.equal( 'abc.css' );
+					expect( compiledThemeCss.toString() ).to.be.equal(
+`/*! baz/qux/theme.scss */
+/*! foo/bar/theme.scss */
+` );
+
+					done();
+				} ) );
+		} );
+	} );
+
+	describe( 'getSassOptions', () => {
+		it( 'should return default options for SASS', () => {
+			const options = utils.getSassOptions( 'foo' );
+
+			expect( options ).to.have.property( 'data', 'foo' );
+			expect( options ).to.have.property( 'sourceMap', true );
+			expect( options ).to.have.property( 'sourceMapEmbed', true );
+			expect( options ).to.have.property( 'outputStyle', 'expanded' );
+			expect( options ).to.have.property( 'sourceComments', true );
+		} );
+	} );
 } );

+ 5 - 0
package.json

@@ -37,8 +37,11 @@
     "gulp-jsdoc3": "^0.2.0",
     "gulp-jshint": "^2.0.0",
     "gulp-mirror": "^1",
+    "gulp-plumber": "^1.1.0",
     "gulp-rename": "^1.2.2",
+    "gulp-sass": "^2.2.0",
     "gulp-sourcemaps": "^1.6.0",
+    "gulp-svg-sprite": "^1.2.19",
     "gulp-util": "^3.0.7",
     "gulp-watch": "^4.3.5",
     "guppy-pre-commit": "^0.3.0",
@@ -53,7 +56,9 @@
     "mockery": "^1.4.0",
     "multipipe": "^0.3.0",
     "ncp": "^2.0.0",
+    "node-sass": "^3.4.2",
     "replace": "^0.3.0",
+    "run-sequence": "^1.1.5",
     "shelljs": "^0",
     "sinon": "^1.17.0",
     "through2": "^2.0.0"