utils.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. const path = require( 'path' );
  7. const gulp = require( 'gulp' );
  8. const rename = require( 'gulp-rename' );
  9. const gulpBabel = require( 'gulp-babel' );
  10. const gutil = require( 'gulp-util' );
  11. const gulpFilter = require( 'gulp-filter' );
  12. const multipipe = require( 'multipipe' );
  13. const PassThrough = require( 'stream' ).PassThrough;
  14. const through = require( 'through2' );
  15. const fs = require( 'fs' );
  16. const sass = require( 'node-sass' );
  17. const del = require( 'del' );
  18. const minimist = require( 'minimist' );
  19. const sprite = require( 'gulp-svg-sprite' );
  20. const pipe = require( 'multipipe' );
  21. const filter = require( 'gulp-filter' );
  22. const utils = {
  23. /**
  24. * Code which can be appended to a transpiled (into AMD) test files in order to
  25. * load the 'tests' module and defer launching Bender until it's ready.
  26. *
  27. * Note: This code will not be transpiled so keep it in ES5.
  28. */
  29. benderLauncherCode:
  30. `
  31. require( [ 'tests' ], bender.defer(), function( err ) {
  32. // The problem with Require.JS is that there are no stacktraces if we won't log this.
  33. console.error( err );
  34. console.log( err.stack );
  35. } );
  36. `,
  37. /**
  38. * Module formats supported by the builder.
  39. */
  40. SUPPORTED_FORMATS: [ 'esnext', 'amd', 'cjs' ],
  41. /**
  42. * Creates a simple duplex stream.
  43. *
  44. * @param {Function} [callback] A callback which will be executed with each chunk.
  45. * @returns {Stream}
  46. */
  47. noop( callback ) {
  48. if ( !callback ) {
  49. return new PassThrough( { objectMode: true } );
  50. }
  51. return through( { objectMode: true }, ( file, encoding, throughCallback ) => {
  52. callback( file );
  53. throughCallback( null, file );
  54. } );
  55. },
  56. /**
  57. * Saves the files piped into this stream to the `build/` directory.
  58. *
  59. * @param {String} buildDir The `build/` directory path.
  60. * @param {String} format The format of the buildribution (`esnext`, `amd`, or `cjs`).
  61. * @returns {Stream}
  62. */
  63. destBuild( buildDir, format ) {
  64. const destDir = path.join( buildDir, format );
  65. return gulp.dest( destDir );
  66. },
  67. /**
  68. * Creates a function generating convertion streams.
  69. * Used to generate `formats.reduce()` callback where `formats` is an array of formats that should be generated.
  70. *
  71. * @param {String} buildDir The `build/` directory path.
  72. * @returns {Function}
  73. */
  74. getConversionStreamGenerator( buildDir ) {
  75. return ( pipes, format ) => {
  76. const conversionPipes = [];
  77. conversionPipes.push( utils.pickVersionedFile( format ) );
  78. if ( format != 'esnext' ) {
  79. // Convert src files.
  80. const filterSource = gulpFilter( ( file ) => {
  81. return utils.isSourceFile( file ) && utils.isJSFile( file );
  82. }, { restore: true } );
  83. const transpileSource = utils.transpile( format, utils.getBabelOptionsForSource( format ) );
  84. conversionPipes.push(
  85. filterSource,
  86. transpileSource,
  87. filterSource.restore
  88. );
  89. // Convert test files.
  90. const filterTests = gulpFilter( ( file ) => {
  91. return utils.isTestFile( file ) && utils.isJSFile( file );
  92. }, { restore: true } );
  93. const transpileTests = utils.transpile( format, utils.getBabelOptionsForTests( format ) );
  94. conversionPipes.push(
  95. filterTests,
  96. transpileTests,
  97. utils.appendBenderLauncher(),
  98. filterTests.restore
  99. );
  100. }
  101. conversionPipes.push(
  102. utils.destBuild( buildDir, format ),
  103. utils.noop( ( file ) => {
  104. gutil.log( `Finished writing '${ gutil.colors.cyan( file.path ) }'` );
  105. } )
  106. );
  107. pipes.push( multipipe.apply( null, conversionPipes ) );
  108. return pipes;
  109. };
  110. },
  111. /**
  112. * Transpiles files piped into this stream to the given format (`amd` or `cjs`).
  113. *
  114. * @param {String} format
  115. * @returns {Stream}
  116. */
  117. transpile( format, options ) {
  118. return gulpBabel( options )
  119. .on( 'error', function( err ) {
  120. gutil.log( gutil.colors.red( `Error (Babel:${ format })` ) );
  121. gutil.log( gutil.colors.red( err.message ) );
  122. console.log( '\n' + err.codeFrame + '\n' );
  123. } );
  124. },
  125. /**
  126. * Returns an object with Babel options for the source code.
  127. *
  128. * @param {String} format
  129. * @returns {Object} options
  130. */
  131. getBabelOptionsForSource( format ) {
  132. return {
  133. plugins: utils.getBabelPlugins( format ),
  134. // Ensure that all paths ends with '.js' because Require.JS (unlike Common.JS/System.JS)
  135. // will not add it to module names which look like paths.
  136. resolveModuleSource: utils.appendModuleExtension
  137. };
  138. },
  139. /**
  140. * Returns an object with Babel options for the test code.
  141. *
  142. * @param {String} format
  143. * @returns {Object} options
  144. */
  145. getBabelOptionsForTests( format ) {
  146. return {
  147. plugins: utils.getBabelPlugins( format ),
  148. resolveModuleSource: utils.appendModuleExtension,
  149. moduleIds: true,
  150. moduleId: 'tests'
  151. };
  152. },
  153. /**
  154. * Returns an array of Babel plugins to use.
  155. *
  156. * @param {String} format
  157. * @returns {Array}
  158. */
  159. getBabelPlugins( format ) {
  160. const babelModuleTranspilers = {
  161. amd: 'amd',
  162. cjs: 'commonjs'
  163. };
  164. const babelModuleTranspiler = babelModuleTranspilers[ format ];
  165. if ( !babelModuleTranspiler ) {
  166. throw new Error( `Incorrect format: ${ format }` );
  167. }
  168. return [
  169. // Note: When plugin is specified by its name, Babel loads it from a context of a
  170. // currently transpiled file (in our case - e.g. from ckeditor5-core/src/foo.js).
  171. // Obviously that fails, since we have all the plugins installed only in ckeditor5/
  172. // and we want to have them only there to avoid installing them dozens of times.
  173. //
  174. // Anyway, I haven't found in the docs that you can also pass a plugin instance here,
  175. // but it works... so let's hope it will.
  176. require( `babel-plugin-transform-es2015-modules-${ babelModuleTranspiler }` )
  177. ];
  178. },
  179. /**
  180. * Appends the {@link #benderLauncherCode} at the end of the file.
  181. *
  182. * @returns {Stream}
  183. */
  184. appendBenderLauncher() {
  185. return through( { objectMode: true }, ( file, encoding, callback ) => {
  186. if ( !file.isNull() ) {
  187. file.contents = new Buffer( file.contents.toString() + utils.benderLauncherCode );
  188. }
  189. callback( null, file );
  190. } );
  191. },
  192. /**
  193. * Allows us to pick one of files suffixed with the format (`__esnext`, `__amd`, or `__cjs`) and removes
  194. * files with other suffixes from the stream.
  195. *
  196. * For example: we have `load__esnext.js`, `load__amd.js` and `load__cjs.js`. After applying this
  197. * transformation when compiling code for a specific format the proper file will be renamed to `load.js`.
  198. * Files not matching a specified format will be removed.
  199. *
  200. * @param {String} format
  201. * @returns {Stream}
  202. */
  203. pickVersionedFile( format ) {
  204. const rejectedFormats = utils.SUPPORTED_FORMATS
  205. .filter( ( item ) => item !== format );
  206. const pickRegexp = new RegExp( `__${ format }$` );
  207. const rejectRegexp = new RegExp( `__(${ rejectedFormats.join( '|' ) }).js$` );
  208. const pick = rename( ( path ) => {
  209. path.basename = path.basename.replace( pickRegexp, '' );
  210. } );
  211. const remove = gulpFilter( ( file ) => !rejectRegexp.test( file.path ) );
  212. return multipipe( pick, remove );
  213. },
  214. /**
  215. * Processes paths of files inside CKEditor5 packages.
  216. *
  217. * * `ckeditor5-xxx/src/foo/bar.js` -> `ckeditor5/xxx/foo/bar.js`
  218. * * `ckeditor5-xxx/tests/foo/bar.js` -> `tests/xxx/foo/bar.js`
  219. *
  220. * @returns {Stream}
  221. */
  222. renamePackageFiles() {
  223. return rename( ( file ) => {
  224. const dirFrags = file.dirname.split( path.sep );
  225. // Validate the input for the clear conscious.
  226. if ( dirFrags[ 0 ].indexOf( 'ckeditor5-' ) !== 0 ) {
  227. throw new Error( 'Path should start with "ckeditor5-".' );
  228. }
  229. dirFrags[ 0 ] = dirFrags[ 0 ].replace( /^ckeditor5-/, '' );
  230. const firstFrag = dirFrags[ 1 ];
  231. if ( firstFrag == 'src' ) {
  232. // Remove 'src/'.
  233. dirFrags.splice( 1, 1 );
  234. // Temporary implementation of the UI lib option. See #88.
  235. if ( dirFrags[ 0 ] == 'ui-default' ) {
  236. dirFrags[ 0 ] = 'ui';
  237. }
  238. // And prepend 'ckeditor5/'.
  239. dirFrags.unshift( 'ckeditor5' );
  240. } else if ( firstFrag == 'tests' ) {
  241. // Remove 'tests/' from the package dir.
  242. dirFrags.splice( 1, 1 );
  243. // And prepend 'tests/'.
  244. dirFrags.unshift( 'tests' );
  245. } else if ( firstFrag == 'theme' ) {
  246. // Remove 'theme/' from the package dir.
  247. // console.log( dirFrags );
  248. // dirFrags.length = 0;
  249. // dirFrags.splice( 1, 2 );
  250. } else {
  251. throw new Error( 'Path should start with "ckeditor5-*/(src|tests|theme)".' );
  252. }
  253. file.dirname = path.join.apply( null, dirFrags );
  254. } );
  255. },
  256. /**
  257. * Processes paths of files inside the main CKEditor5 package.
  258. *
  259. * * `src/foo/bar.js` -> `ckeditor5/foo/bar.js`
  260. * * `tests/foo/bar.js` -> `tests/ckeditor5/foo/bar.js`
  261. *
  262. * @returns {Stream}
  263. */
  264. renameCKEditor5Files() {
  265. return rename( ( file ) => {
  266. const dirFrags = file.dirname.split( path.sep );
  267. const firstFrag = dirFrags[ 0 ];
  268. if ( firstFrag == 'src' ) {
  269. // Replace 'src/' with 'ckeditor5/'.
  270. // src/path.js -> ckeditor5/path.js
  271. dirFrags.splice( 0, 1, 'ckeditor5' );
  272. } else if ( firstFrag == 'tests' ) {
  273. // Insert 'ckeditor5/' after 'tests/'.
  274. // tests/foo.js -> tests/ckeditor5/foo.js
  275. dirFrags.splice( 1, 0, 'ckeditor5' );
  276. } else {
  277. throw new Error( 'Path should start with "src" or "tests".' );
  278. }
  279. file.dirname = path.join.apply( null, dirFrags );
  280. } );
  281. },
  282. /**
  283. * Appends file extension to file URLs. Tries to not touch named modules.
  284. *
  285. * @param {String} source
  286. * @returns {String}
  287. */
  288. appendModuleExtension( source ) {
  289. if ( /^https?:|\.[\/\\]/.test( source ) && !/\.js$/.test( source ) ) {
  290. return source + '.js';
  291. }
  292. return source;
  293. },
  294. /**
  295. * Checks whether a file is a test file.
  296. *
  297. * @param {Vinyl} file
  298. * @returns {Boolean}
  299. */
  300. isTestFile( file ) {
  301. // TODO this should be based on bender configuration (config.tests.*.paths).
  302. if ( !file.relative.startsWith( 'tests' + path.sep ) ) {
  303. return false;
  304. }
  305. const dirFrags = file.relative.split( path.sep );
  306. return !dirFrags.some( dirFrag => {
  307. return dirFrag.startsWith( '_' ) && dirFrag != '_utils-tests';
  308. } );
  309. },
  310. /**
  311. * Checks whether a file is a source file.
  312. *
  313. * @param {Vinyl} file
  314. * @returns {Boolean}
  315. */
  316. isSourceFile( file ) {
  317. return !utils.isTestFile( file );
  318. },
  319. /**
  320. * Checks whether a file is a JS file.
  321. *
  322. * @param {Vinyl} file
  323. * @returns {Boolean}
  324. */
  325. isJSFile( file ) {
  326. return file.path.endsWith( '.js' );
  327. },
  328. /**
  329. * Finds all CKEditor5 package directories in "node_modules" folder.
  330. *
  331. * @param {String} rootDir A root directory containing "node_modules" folder.
  332. * @returns {Array} Array of ckeditor5-* package directory paths.
  333. */
  334. getPackages( rootDir ) {
  335. // Find all CKEditor5 package directories. Resolve symlinks so we watch real directories
  336. // in order to workaround https://github.com/paulmillr/chokidar/issues/419.
  337. return fs.readdirSync( path.join( rootDir, 'node_modules' ) )
  338. // Look for ckeditor5-* directories.
  339. .filter( ( fileName ) => fileName.indexOf( 'ckeditor5-' ) === 0 )
  340. // Resolve symlinks and keep only directories.
  341. .map( ( fileName ) => {
  342. let filePath = path.join( rootDir, 'node_modules', fileName );
  343. let stat = fs.lstatSync( filePath );
  344. if ( stat.isSymbolicLink() ) {
  345. filePath = fs.realpathSync( filePath );
  346. stat = fs.lstatSync( filePath );
  347. }
  348. if ( stat.isDirectory() ) {
  349. return filePath;
  350. }
  351. // Filter...
  352. return false;
  353. } )
  354. // ...those out.
  355. .filter( ( filePath ) => filePath );
  356. },
  357. /**
  358. * Filters theme entry points only from a stream of SCSS files.
  359. *
  360. * @returns {Stream}
  361. */
  362. filterThemeEntryPoints() {
  363. return filter( '**/theme.scss' );
  364. },
  365. /**
  366. * Given the input stream of theme entry-point files (theme.scss), this method:
  367. * 1. Collects paths to entry-point.
  368. * 2. Builds the output CSS theme file using aggregated entry-points.
  369. * 3. Returns a stream containing built CSS theme file.
  370. *
  371. * @param {String} fileName The name of the output CSS theme file.
  372. * @returns {Stream}
  373. */
  374. compileThemes( fileName ) {
  375. const paths = [];
  376. const stream = through.obj( collectThemeEntryPoint, renderThemeFromEntryPoints );
  377. function collectThemeEntryPoint( file, enc, callback ) {
  378. paths.push( file.path );
  379. callback();
  380. }
  381. function renderThemeFromEntryPoints( callback ) {
  382. gutil.log( `Compiling '${ gutil.colors.cyan( fileName ) }' from ${ gutil.colors.cyan( paths.length ) } entry points...` );
  383. const dataToRender = paths.map( p => `@import '${ p }';` )
  384. .join( '\n' );
  385. try {
  386. const rendered = sass.renderSync( utils.getSassOptions( dataToRender ) );
  387. stream.push( new gutil.File( {
  388. path: fileName,
  389. contents: new Buffer( rendered.css )
  390. } ) );
  391. callback();
  392. } catch ( err ) {
  393. callback( err );
  394. }
  395. }
  396. return stream;
  397. },
  398. /**
  399. * Parses command line arguments and returns them as a user-friendly hash.
  400. *
  401. * @param {String} dataToRender
  402. * @returns {Object}
  403. */
  404. getSassOptions( dataToRender ) {
  405. return {
  406. data: dataToRender,
  407. sourceMap: true,
  408. sourceMapEmbed: true,
  409. outputStyle: 'expanded',
  410. sourceComments: true
  411. };
  412. },
  413. /**
  414. * Removes files and directories specified by `glob` starting from `rootDir`
  415. * and gently informs about deletion.
  416. *
  417. * @param {String} rootDir The path to the root directory (i.e. "dist/").
  418. * @param {String} glob Glob specifying what to clean.
  419. * @returns {Promise}
  420. */
  421. clean( rootDir, glob ) {
  422. return del( path.join( rootDir, glob ) ).then( paths => {
  423. paths.forEach( p => {
  424. gutil.log( `Deleted file '${ gutil.colors.cyan( p ) }'.` );
  425. } );
  426. } );
  427. },
  428. /**
  429. * Parses command line arguments and returns them as a user-friendly hash.
  430. *
  431. * @returns {Object} options
  432. * @returns {Array} [options.formats] Array of specified output formats ("esnext" or "amd").
  433. * @returns {Boolean} [options.watch] A flag which enables watch mode.
  434. */
  435. parseArguments() {
  436. const options = minimist( process.argv.slice( 2 ), {
  437. string: [
  438. 'formats'
  439. ],
  440. boolean: [
  441. 'watch'
  442. ],
  443. default: {
  444. formats: 'amd',
  445. watch: false
  446. }
  447. } );
  448. options.formats = options.formats.split( ',' );
  449. return options;
  450. },
  451. /**
  452. * Given a stream of .svg files it returns a stream containing JavaScript
  453. * icon sprite file.
  454. *
  455. * @returns {Stream}
  456. */
  457. compileIconSprite() {
  458. return sprite( utils.getIconSpriteOptions() );
  459. },
  460. /**
  461. * Returns svg-sprite util options to generate <symbol>-based, JavaScript
  462. * sprite file.
  463. *
  464. * @returns {Object}
  465. */
  466. getIconSpriteOptions() {
  467. return {
  468. shape: {
  469. id: {
  470. generator: name => `ck-icon-${ name.match( /([^\/]*)\.svg$/ )[ 1 ] }`
  471. },
  472. },
  473. svg: {
  474. xmlDeclaration: false,
  475. doctypeDeclaration: false,
  476. },
  477. mode: {
  478. symbol: {
  479. dest: './', // Flatten symbol/
  480. inline: true,
  481. render: {
  482. js: {
  483. template: path.join( __dirname, 'iconmanagermodel.tpl' ),
  484. dest: 'iconmanagermodel.js',
  485. }
  486. }
  487. }
  488. }
  489. };
  490. },
  491. /**
  492. * Given a stream of files it returns an array of gulp-mirror streams outputting
  493. * files to `build/[formats]/theme/` directories for each of desired output formats (cjs, amd, etc.).
  494. *
  495. * @param {String} buildDir A path to /build directory.
  496. * @param {Array} formats An array of desired output formats.
  497. * @param {Function} [transformationStream] A stream used to transform files before they're saved to
  498. * desired `build/[formats]/theme` directories. Useful for transpilation.
  499. * @returns {Stream[]} An array of streams.
  500. */
  501. getThemeFormatDestStreams( buildDir, formats, transformationStream ) {
  502. return formats.map( f => {
  503. return pipe(
  504. transformationStream ? transformationStream( f ) : utils.noop(),
  505. gulp.dest( path.join( buildDir, f, 'theme' ) ),
  506. utils.noop( ( file ) => {
  507. gutil.log( `Output for ${ gutil.colors.cyan( f ) } is '${ gutil.colors.cyan( file.path ) }'.` );
  508. } )
  509. );
  510. } );
  511. }
  512. };
  513. module.exports = utils;