瀏覽代碼

Merge pull request #132 from ckeditor/t/101

t/101: Implement the themes architecture
Aleksander Nowodzinski 9 年之前
父節點
當前提交
6157081026

+ 16 - 0
dev/tasks/build/iconmanagermodel.tpl

@@ -0,0 +1,16 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Model from '/ckeditor5/ui/model.js';
+
+const iconIds = [ {{#shapes}}'{{name}}',{{/shapes}} ];
+const iconPrefix = 'ck-icon-';
+
+export default new Model( {
+	icons: iconIds.map( i => i.slice( iconPrefix.length ) ),
+	sprite: `{{#shapes}}{{{svg}}}{{/shapes}}`
+} );

+ 274 - 178
dev/tasks/build/tasks.js

@@ -5,240 +5,336 @@
 
 
 'use strict';
 'use strict';
 
 
-const fs = require( 'fs' );
 const path = require( 'path' );
 const path = require( 'path' );
 const gulp = require( 'gulp' );
 const gulp = require( 'gulp' );
-const del = require( 'del' );
 const merge = require( 'merge-stream' );
 const merge = require( 'merge-stream' );
-const gulpMirror = require( 'gulp-mirror' );
+const mirror = require( 'gulp-mirror' );
 const gulpWatch = require( 'gulp-watch' );
 const gulpWatch = require( 'gulp-watch' );
+const gulpPlumber = require( 'gulp-plumber' );
 const gutil = require( 'gulp-util' );
 const gutil = require( 'gulp-util' );
-const minimist = require( 'minimist' );
+const filter = require( 'gulp-filter' );
 const utils = require( './utils' );
 const utils = require( './utils' );
+const runSequence = require( 'run-sequence' );
 
 
 module.exports = ( config ) => {
 module.exports = ( config ) => {
 	const buildDir = path.join( config.ROOT_DIR, config.BUILD_DIR );
 	const buildDir = path.join( config.ROOT_DIR, config.BUILD_DIR );
+	const themesGlob = path.join( 'theme', '**', '*.scss' );
+	const iconsGlob = path.join( 'theme', 'icons', '*.svg' );
+	const args = utils.parseArguments();
 
 
 	const tasks = {
 	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' );
+			js( options ) {
+				// TODO: ES6 default function parameters
+				options = options || utils.parseArguments();
 
 
-				return gulp.src( glob )
-					.pipe( watch ? gulpWatch( glob ) : utils.noop() );
+				return utils.clean( buildDir, path.join( `@(${ options.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}
 			 * @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 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 baseDir = path.parse( dirPath ).dir;
 					const opts = { base: baseDir, nodir: true };
 					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;
+
+				startStreams();
+
+				return outputStream;
 
 
-			// 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 );
+				// Creates a single stream combining multiple conversion streams.
+				function createConversionStream() {
+					const formatPipes = options.formats.reduce( conversionStreamGenerator, [] );
 
 
-					return;
+					return mirror.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 unpipeStreams() {
-				codeStream.unpipe( inputStream );
-				conversionStream.unpipe( outputStream );
-			}
-		}
-	};
+				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 `build/[formats]/theme`
+			 * directories.
+			 *
+			 * @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 );
 
 
-	gulp.task( 'build:clean', tasks.clean );
+					// Initial build.
+					build();
 
 
-	gulp.task( 'build', [ 'build:clean' ], () => {
-		const knownOptions = {
-			string: [
-				'formats'
-			],
+					gutil.log( `Watching theme files in '${ gutil.colors.cyan( glob ) }' for changes...` );
 
 
-			boolean: [
-				'watch'
-			],
+					return gulp.watch( glob, event => {
+						gutil.log( `Theme file '${ gutil.colors.cyan( event.path ) }' has been ${ event.type }...` );
 
 
-			default: {
-				formats: 'amd',
-				watch: false
-			}
-		};
+						// Re-build the entire theme if the file has been changed.
+						return build();
+					} );
+				} else {
+					return build();
+				}
+
+				function build() {
+					const formatStreams = utils.getThemeFormatDestStreams( buildDir, options.formats );
+
+					return tasks.src.sass()
+						.pipe( gulpPlumber() )
+						.pipe( utils.filterThemeEntryPoints() )
+						.pipe(
+							utils.noop( file => {
+								gutil.log( `Found theme entry point '${ gutil.colors.cyan( file.path ) }'.` );
+							} )
+						)
+						.pipe( utils.compileThemes( 'ckeditor.css' ) )
+						.pipe( mirror( formatStreams ) )
+						.on( 'error', console.log );
+				}
+			},
+
+			/**
+			 * The task capable of converting *.svg icon files into `./build/[formats]/theme/iconmanagermodel.js`
+			 * sprite.
+			 *
+			 * @param {Object} options
+			 * @param {String} options.formats
+			 * @returns {Stream}
+			 */
+			icons( options ) {
+				const formatStreams = utils.getThemeFormatDestStreams( buildDir, options.formats, format => {
+					if ( format !== 'esnext' ) {
+						return utils.transpile( format, utils.getBabelOptionsForSource( format ) );
+					} else {
+						return utils.noop();
+					}
+				} );
 
 
-		const options = minimist( process.argv.slice( 2 ), knownOptions );
+				return tasks.src.icons()
+					.pipe( utils.compileIconSprite() )
+					.pipe( filter( '*.js' ) )
+					.pipe( mirror( formatStreams ) );
+			}
+		}
+	};
 
 
-		return tasks.build( options );
+	gulp.task( 'build', callback => {
+		runSequence( 'build:clean:all', 'build:themes', 'build:js', callback );
 	} );
 	} );
 
 
-	gulp.task( 'build-esnext', [ 'build:clean' ], () => {
-		return tasks.build( { formats: 'esnext' } );
+	gulp.task( 'build:clean:all', tasks.clean.all );
+	gulp.task( 'build:clean:themes', tasks.clean.themes );
+	gulp.task( 'build:clean:js', () => tasks.clean.js( args ) );
+
+	gulp.task( 'build:themes', ( callback ) => {
+		runSequence( 'build:clean:themes', 'build:icons', 'build:sass', callback );
 	} );
 	} );
 
 
+	gulp.task( 'build:sass', () => tasks.build.sass( args ) );
+	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.
+	gulp.task( 'build:clean:js:esnext', () => tasks.clean.js( { formats: [ 'esnext' ] } ) );
+	gulp.task( 'build:js:esnext', [ 'build:clean:js:esnext' ], () => tasks.build.js( { formats: [ 'esnext' ] } ) );
+
 	return tasks;
 	return tasks;
 };
 };

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

@@ -14,6 +14,13 @@ const gulpFilter = require( 'gulp-filter' );
 const multipipe = require( 'multipipe' );
 const multipipe = require( 'multipipe' );
 const PassThrough = require( 'stream' ).PassThrough;
 const PassThrough = require( 'stream' ).PassThrough;
 const through = require( 'through2' );
 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 utils = {
 const utils = {
 	/**
 	/**
@@ -272,7 +279,7 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 				// And prepend 'tests/'.
 				// And prepend 'tests/'.
 				dirFrags.unshift( 'tests' );
 				dirFrags.unshift( 'tests' );
 			} else {
 			} 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 );
 			file.dirname = path.join.apply( null, dirFrags );
@@ -359,6 +366,216 @@ require( [ 'tests' ], bender.defer(), function( err ) {
 	 */
 	 */
 	isJSFile( file ) {
 	isJSFile( file ) {
 		return file.path.endsWith( '.js' );
 		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 => {
+				return 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 );
+	},
+
+	/**
+	 * Filters theme entry points only from a stream of SCSS files.
+	 *
+	 * @returns {Stream}
+	 */
+	filterThemeEntryPoints() {
+		return filter( '**/theme.scss' );
+	},
+
+	/**
+	 * 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 '${ gutil.colors.cyan( fileName ) }' from ${ gutil.colors.cyan( paths.length ) } entry points...` );
+
+			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;
+	},
+
+	/**
+	 * 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
+		};
+	},
+
+	/**
+	 * 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;
+	},
+
+	/**
+	 * Given a stream of .svg files it returns a stream containing JavaScript
+	 * icon sprite file.
+	 *
+	 * @returns {Stream}
+	 */
+	compileIconSprite() {
+		return sprite( utils.getIconSpriteOptions() );
+	},
+
+	/**
+	 * Returns svg-sprite util options to generate <symbol>-based, JavaScript
+	 * sprite file.
+	 *
+	 * @returns {Object}
+	 */
+	getIconSpriteOptions() {
+		return {
+			shape: {
+				id: {
+					generator: name => `ck-icon-${ name.match( /([^\/]*)\.svg$/ )[ 1 ] }`
+				},
+			},
+			svg: {
+				xmlDeclaration: false,
+				doctypeDeclaration: false,
+			},
+			mode: {
+				symbol: {
+					dest: './', // Flatten symbol/ folder.
+					inline: true,
+					render: {
+						js: {
+							template: path.join( __dirname, 'iconmanagermodel.tpl' ),
+							dest: 'iconmanagermodel.js',
+						}
+					}
+				}
+			}
+		};
+	},
+
+	/**
+	 * Given a stream of files it returns an array of gulp-mirror streams outputting
+	 * files to `build/[formats]/theme/` directories for each of desired output formats (cjs, amd, etc.).
+	 *
+	 * @param {String} buildDir A path to /build directory.
+	 * @param {Array} formats An array of desired output formats.
+	 * @param {Function} [transformationStream] A stream used to transform files before they're saved to
+	 * desired `build/[formats]/theme` directories. Useful for transpilation.
+	 * @returns {Stream[]} An array of streams.
+	 */
+	getThemeFormatDestStreams( buildDir, formats, transformationStream ) {
+		return formats.map( f => {
+			return pipe(
+				transformationStream ? transformationStream( f ) : utils.noop(),
+				gulp.dest( path.join( buildDir, f, 'theme' ) ),
+				utils.noop( file => {
+					gutil.log( `Output for ${ gutil.colors.cyan( f ) } is '${ gutil.colors.cyan( file.path ) }'.` );
+				} )
+			);
+		} );
 	}
 	}
 };
 };
 
 

+ 1 - 1
dev/tasks/docs/tasks.js

@@ -10,7 +10,7 @@ const jsdoc = require( 'gulp-jsdoc3' );
 const path = require( 'path' );
 const path = require( 'path' );
 
 
 module.exports = ( config ) => {
 module.exports = ( config ) => {
-	gulp.task( 'docs', [ 'build-esnext' ], function( cb ) {
+	gulp.task( 'docs', [ 'build:js:esnext' ], function( cb ) {
 		const esnextBuildPath = path.join( config.ROOT_DIR, config.BUILD_DIR, 'esnext' );
 		const esnextBuildPath = path.join( config.ROOT_DIR, config.BUILD_DIR, 'esnext' );
 		const jsDocConfig = {
 		const jsDocConfig = {
 			opts: {
 			opts: {

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

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

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

@@ -528,4 +528,196 @@ describe( 'build-utils', () => {
 		test( 'foo/tests/file.js', false );
 		test( 'foo/tests/file.js', false );
 		test( 'tests/_foo/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 only', ( 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 );
+		} );
+	} );
+
+	describe( 'parseArguments', () => {
+		it( 'returns object with defaults', () => {
+			const args = utils.parseArguments();
+
+			expect( args.formats ).to.have.members( [ 'amd' ] );
+			expect( args.watch ).to.be.equal( false );
+		} );
+	} );
+
+	describe( 'getIconSpriteOptions', () => {
+		it( 'returns object with defaults', () => {
+			const options = utils.getIconSpriteOptions();
+
+			expect( options ).to.have.all.keys( [ 'shape', 'svg', 'mode' ] );
+		} );
+
+		it( 'returns icon ids generator out of svg file names', () => {
+			const options = utils.getIconSpriteOptions();
+
+			expect( options.shape.id.generator( 'foo.svg' ) ).to.equal( 'ck-icon-foo' );
+		} );
+
+		it( 'returns configuration to output JavaScript sprite', () => {
+			const options = utils.getIconSpriteOptions();
+
+			expect( options.mode.symbol.render.js.dest ).to.equal( 'iconmanagermodel.js' );
+		} );
+	} );
+
+	describe( 'getThemeFormatDestStreams', () => {
+		it( 'returns array of streams for each format', () => {
+			const streams = utils.getThemeFormatDestStreams( 'foo', [ 'a', 'b' ] );
+
+			expect( streams ).to.be.an( 'array' );
+			expect( streams ).to.have.length( 2 );
+		} );
+	} );
 } );
 } );

+ 6 - 0
package.json

@@ -9,6 +9,7 @@
   ],
   ],
   "dependencies": {
   "dependencies": {
     "ckeditor5-engine": "ckeditor/ckeditor5-engine",
     "ckeditor5-engine": "ckeditor/ckeditor5-engine",
+    "ckeditor5-theme-lark": "ckeditor/ckeditor5-theme-lark",
     "ckeditor5-ui": "ckeditor/ckeditor5-ui",
     "ckeditor5-ui": "ckeditor/ckeditor5-ui",
     "ckeditor5-ui-default": "ckeditor/ckeditor5-ui-default",
     "ckeditor5-ui-default": "ckeditor/ckeditor5-ui-default",
     "ckeditor5-utils": "ckeditor/ckeditor5-utils",
     "ckeditor5-utils": "ckeditor/ckeditor5-utils",
@@ -37,8 +38,11 @@
     "gulp-jsdoc3": "^0.2.0",
     "gulp-jsdoc3": "^0.2.0",
     "gulp-jshint": "^2.0.0",
     "gulp-jshint": "^2.0.0",
     "gulp-mirror": "^1",
     "gulp-mirror": "^1",
+    "gulp-plumber": "^1.1.0",
     "gulp-rename": "^1.2.2",
     "gulp-rename": "^1.2.2",
+    "gulp-sass": "^2.2.0",
     "gulp-sourcemaps": "^1.6.0",
     "gulp-sourcemaps": "^1.6.0",
+    "gulp-svg-sprite": "^1.2.19",
     "gulp-util": "^3.0.7",
     "gulp-util": "^3.0.7",
     "gulp-watch": "^4.3.5",
     "gulp-watch": "^4.3.5",
     "guppy-pre-commit": "^0.3.0",
     "guppy-pre-commit": "^0.3.0",
@@ -53,7 +57,9 @@
     "mockery": "^1.4.0",
     "mockery": "^1.4.0",
     "multipipe": "^0.3.0",
     "multipipe": "^0.3.0",
     "ncp": "^2.0.0",
     "ncp": "^2.0.0",
+    "node-sass": "^3.4.2",
     "replace": "^0.3.0",
     "replace": "^0.3.0",
+    "run-sequence": "^1.1.5",
     "shelljs": "^0",
     "shelljs": "^0",
     "sinon": "^1.17.0",
     "sinon": "^1.17.0",
     "through2": "^2.0.0"
     "through2": "^2.0.0"

+ 1 - 1
tests/_utils/ui/boxededitorui/boxededitorui.js

@@ -13,7 +13,7 @@ export default class BoxedEditorUI extends EditorUI {
 		super( editor );
 		super( editor );
 
 
 		this.collections.add( new ControllerCollection( 'top' ) );
 		this.collections.add( new ControllerCollection( 'top' ) );
-		this.collections.add( new ControllerCollection( 'main' ) );
+		this.collections.add( new ControllerCollection( 'editable' ) );
 
 
 		const config = editor.config;
 		const config = editor.config;
 
 

+ 5 - 6
tests/_utils/ui/boxededitorui/boxededitoruiview.js

@@ -15,27 +15,26 @@ export default class BoxedEditorUIView extends EditorUIView {
 			tag: 'div',
 			tag: 'div',
 
 
 			attributes: {
 			attributes: {
-				class: 'ck-box'
+				class: 'ck-reset ck-editor'
 			},
 			},
 
 
 			children: [
 			children: [
 				{
 				{
 					tag: 'div',
 					tag: 'div',
 					attributes: {
 					attributes: {
-						class: 'ck-box-region ck-top'
+						class: 'ck-editor-top ck-reset-all'
 					}
 					}
 				},
 				},
-
 				{
 				{
 					tag: 'div',
 					tag: 'div',
 					attributes: {
 					attributes: {
-						class: 'ck-box-region ck-main'
+						class: 'ck-editor-editable'
 					}
 					}
 				}
 				}
 			]
 			]
 		};
 		};
 
 
-		this.register( 'top', '.ck-top' );
-		this.register( 'main', '.ck-main' );
+		this.register( 'top', '.ck-editor-top' );
+		this.register( 'editable', '.ck-editor-editable' );
 	}
 	}
 }
 }

+ 1 - 1
tests/_utils/ui/editableui/framed/framededitableuiview.js

@@ -26,7 +26,7 @@ export default class FramedEditableUIView extends EditableUIView {
 		this.template = {
 		this.template = {
 			tag: 'iframe',
 			tag: 'iframe',
 			attributes: {
 			attributes: {
-				class: [ 'ck-framededitable' ],
+				class: [ 'ck-framededitable ck-reset-all' ],
 				// It seems that we need to allow scripts in order to be able to listen to events.
 				// It seems that we need to allow scripts in order to be able to listen to events.
 				// TODO: Research that. Perhaps the src must be set?
 				// TODO: Research that. Perhaps the src must be set?
 				sandbox: 'allow-same-origin allow-scripts',
 				sandbox: 'allow-same-origin allow-scripts',

+ 1 - 1
tests/creator/manual/_utils/creator/classiccreator.js

@@ -47,7 +47,7 @@ export default class ClassicCreator extends StandardCreator {
 		// UI.
 		// UI.
 		this._replaceElement( editor.firstElement, editor.ui.view.element );
 		this._replaceElement( editor.firstElement, editor.ui.view.element );
 		this._createToolbar();
 		this._createToolbar();
-		editor.ui.add( 'main', createEditableUI( editor, editable, FramedEditableUI, FramedEditableUIView ) );
+		editor.ui.add( 'editable', createEditableUI( editor, editable, FramedEditableUI, FramedEditableUIView ) );
 
 
 		// Init.
 		// Init.
 		return super.create()
 		return super.create()

+ 1 - 1
tests/creator/manual/_utils/creator/multicreator.js

@@ -45,7 +45,7 @@ export default class MultiCreator extends StandardCreator {
 		this._createToolbar();
 		this._createToolbar();
 
 
 		for ( let editable of editor.editables ) {
 		for ( let editable of editor.editables ) {
-			editor.ui.add( 'main', createEditableUI( editor, editable, EditableUI, InlineEditableUIView ) );
+			editor.ui.add( 'editable', createEditableUI( editor, editable, EditableUI, InlineEditableUIView ) );
 		}
 		}
 
 
 		editor.elements.forEach( ( element ) => {
 		editor.elements.forEach( ( element ) => {

+ 4 - 2
tests/creator/manual/_utils/imitatefeatures.js

@@ -20,7 +20,8 @@ export function imitateFeatures( editor ) {
 	const boldModel = new Model( {
 	const boldModel = new Model( {
 		isEnabled: true,
 		isEnabled: true,
 		isOn: false,
 		isOn: false,
-		label: t( 'Bold' )
+		label: t( 'Bold' ),
+		icon: 'bold'
 	} );
 	} );
 
 
 	boldModel.on( 'execute', () => {
 	boldModel.on( 'execute', () => {
@@ -35,7 +36,8 @@ export function imitateFeatures( editor ) {
 	const italicModel = new Model( {
 	const italicModel = new Model( {
 		isEnabled: true,
 		isEnabled: true,
 		isOn: false,
 		isOn: false,
-		label: t( 'Italic' )
+		label: t( 'Italic' ),
+		icon: 'italic'
 	} );
 	} );
 
 
 	italicModel.on( 'execute', () => {
 	italicModel.on( 'execute', () => {

+ 1 - 1
tests/creator/manual/creator-classic.html

@@ -1,5 +1,5 @@
 <head>
 <head>
-	<link rel="stylesheet" href="%TEST_DIR%_assets/styles.css">
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/amd/theme/ckeditor.css">
 </head>
 </head>
 
 
 <p>
 <p>

+ 1 - 1
tests/creator/manual/creator-inline.html

@@ -1,5 +1,5 @@
 <head>
 <head>
-	<link rel="stylesheet" href="%TEST_DIR%_assets/styles.css">
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/amd/theme/ckeditor.css">
 </head>
 </head>
 
 
 <p>
 <p>

+ 1 - 1
tests/creator/manual/creator-multi.html

@@ -1,5 +1,5 @@
 <head>
 <head>
-	<link rel="stylesheet" href="%TEST_DIR%_assets/styles.css">
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/amd/theme/ckeditor.css">
 </head>
 </head>
 
 
 <p>
 <p>