build-content-styles.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* eslint-env node */
  6. const cwd = process.cwd();
  7. const path = require( 'path' );
  8. const fs = require( 'fs' );
  9. const chalk = require( 'chalk' );
  10. const glob = require( 'glob' );
  11. const mkdirp = require( 'mkdirp' );
  12. const postcss = require( 'postcss' );
  13. const webpack = require( 'webpack' );
  14. const Table = require( 'cli-table' );
  15. const { tools, styles } = require( '@ckeditor/ckeditor5-dev-utils' );
  16. const { version } = require( '../../package.json' );
  17. const DESTINATION_DIRECTORY = path.join( __dirname, '..', '..', 'build', 'content-styles' );
  18. const CONTENT_STYLES_GUIDE_PATH = path.join( __dirname, '..', '..', 'docs', 'builds', 'guides', 'integration', 'content-styles.md' );
  19. const CONTENT_STYLES_DETAILS_PATH = path.join( __dirname, 'content-styles-details.json' );
  20. const DOCUMENTATION_URL = 'https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html';
  21. const VARIABLE_DEFINITION_REGEXP = /(--[\w-]+):\s+(.*);/g;
  22. const VARIABLE_USAGE_REGEXP = /var\((--[\w-]+)\)/g;
  23. const contentStylesDetails = require( CONTENT_STYLES_DETAILS_PATH );
  24. // An array of objects with plugins used to generate the current version of the content styles.
  25. let foundModules;
  26. const contentRules = {
  27. selector: [],
  28. variables: [],
  29. atRules: {}
  30. };
  31. const packagesPath = path.join( cwd, 'packages' );
  32. const shouldCommitChanges = process.argv.includes( '--commit' );
  33. logProcess( 'Gathering all CKEditor 5 modules...' );
  34. getCkeditor5ModulePaths()
  35. .then( files => {
  36. console.log( `Found ${ files.length } files.` );
  37. logProcess( 'Filtering CKEditor 5 plugins...' );
  38. let promise = Promise.resolve();
  39. const ckeditor5Modules = [];
  40. for ( const modulePath of files ) {
  41. promise = promise.then( () => {
  42. return checkWhetherIsCKEditor5Plugin( modulePath )
  43. .then( isModule => {
  44. if ( isModule ) {
  45. ckeditor5Modules.push( path.join( cwd, modulePath ) );
  46. }
  47. } );
  48. } );
  49. }
  50. return promise.then( () => ckeditor5Modules );
  51. } )
  52. .then( ckeditor5Modules => {
  53. console.log( `Found ${ ckeditor5Modules.length } plugins.` );
  54. logProcess( 'Generating source file...' );
  55. return mkdirp( DESTINATION_DIRECTORY ).then( () => generateCKEditor5Source( ckeditor5Modules ) );
  56. } )
  57. .then( ckeditor5Modules => {
  58. foundModules = ckeditor5Modules;
  59. logProcess( 'Building the editor...' );
  60. const webpackConfig = getWebpackConfig();
  61. return runWebpack( webpackConfig );
  62. } )
  63. .then( () => {
  64. logProcess( 'Preparing the content styles file...' );
  65. // All variables are placed inside the `:root` selector. Let's extract their names and values as a map.
  66. const cssVariables = new Map( contentRules.variables
  67. .map( rule => {
  68. // Let's extract all of them as an array of pairs: [ name, value ].
  69. const allRules = [];
  70. let match;
  71. while ( ( match = VARIABLE_DEFINITION_REGEXP.exec( rule.css ) ) ) {
  72. allRules.push( [ match[ 1 ], match[ 2 ] ] );
  73. }
  74. return allRules;
  75. } )
  76. .reduce( ( previousValue, currentValue ) => {
  77. // And simplify nested arrays as a flattened array.
  78. previousValue.push( ...currentValue );
  79. return previousValue;
  80. }, [] ) );
  81. // CSS variables that are used by the `.ck-content` selector.
  82. const usedVariables = new Set();
  83. // `.ck-content` selectors.
  84. const selectorCss = transformCssRules( contentRules.selector );
  85. // Find all CSS variables inside the `.ck-content` selector.
  86. let match;
  87. while ( ( match = VARIABLE_USAGE_REGEXP.exec( selectorCss ) ) ) {
  88. usedVariables.add( match[ 1 ] );
  89. }
  90. // We need to also look at whether any of the used variables requires the value of other variables.
  91. let clearRun = false;
  92. // We need to process all variables as long as the entire collection won't be changed.
  93. while ( !clearRun ) {
  94. clearRun = true;
  95. // For every used variable...
  96. for ( const variable of usedVariables ) {
  97. const value = cssVariables.get( variable );
  98. let match;
  99. // ...find its value and check whether it requires another variable.
  100. while ( ( match = VARIABLE_USAGE_REGEXP.exec( value ) ) ) {
  101. // If so, mark the entire `while()` block as it should be checked once again.
  102. // Also, add the new variable to the used variables collection.
  103. if ( !usedVariables.has( match[ 1 ] ) ) {
  104. clearRun = false;
  105. usedVariables.add( match[ 1 ] );
  106. }
  107. }
  108. }
  109. }
  110. const atRulesDefinitions = [];
  111. // Additional at-rules.
  112. for ( const atRuleName of Object.keys( contentRules.atRules ) ) {
  113. const rules = transformCssRules( contentRules.atRules[ atRuleName ] )
  114. .split( '\n' )
  115. .map( line => `\t${ line }` )
  116. .join( '\n' );
  117. atRulesDefinitions.push( `@${ atRuleName } {\n${ rules }\n}` );
  118. }
  119. // Build the final content of the CSS file.
  120. let data = [
  121. '/*',
  122. ` * CKEditor 5 (v${ version }) content styles.`,
  123. ` * Generated on ${ new Date().toUTCString() }.`,
  124. ` * For more information, check out ${ DOCUMENTATION_URL }`,
  125. ' */\n\n'
  126. ].join( '\n' );
  127. data += ':root {\n';
  128. for ( const variable of [ ...usedVariables ].sort() ) {
  129. data += `\t${ variable }: ${ cssVariables.get( variable ) };\n`;
  130. }
  131. data += '}\n\n';
  132. data += selectorCss;
  133. data += '\n';
  134. data += atRulesDefinitions.join( '\n' );
  135. return writeFile( path.join( DESTINATION_DIRECTORY, 'content-styles.css' ), data );
  136. } )
  137. .then( () => {
  138. console.log( `Content styles have been extracted to ${ path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) }` );
  139. logProcess( 'Looking for new plugins...' );
  140. const newPlugins = findNewPlugins( foundModules, contentStylesDetails.plugins );
  141. if ( newPlugins.length ) {
  142. console.log( 'Found new plugins.' );
  143. displayNewPluginsTable( newPlugins );
  144. } else {
  145. console.log( 'Previous and current versions of the content styles stylesheet were generated with the same set of plugins.' );
  146. }
  147. if ( !shouldCommitChanges ) {
  148. logProcess( 'Done.' );
  149. return Promise.resolve();
  150. }
  151. if ( newPlugins.length ) {
  152. logProcess( 'Updating the content styles details file...' );
  153. tools.updateJSONFile( CONTENT_STYLES_DETAILS_PATH, json => {
  154. const newPluginsObject = {};
  155. for ( const data of foundModules ) {
  156. const modulePath = normalizePath( data.modulePath.replace( cwd + path.sep, '' ) );
  157. newPluginsObject[ modulePath ] = data.pluginName;
  158. }
  159. json.plugins = newPluginsObject;
  160. return json;
  161. } );
  162. }
  163. logProcess( 'Updating the content styles guide...' );
  164. const promises = [
  165. readFile( CONTENT_STYLES_GUIDE_PATH ),
  166. readFile( path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) )
  167. ];
  168. return Promise.all( promises )
  169. .then( ( [ guideContent, newContentStyles ] ) => {
  170. guideContent = guideContent.replace( /```css([^`]+)```/, '```css\n' + newContentStyles + '\n```' );
  171. return writeFile( CONTENT_STYLES_GUIDE_PATH, guideContent );
  172. } )
  173. .then( () => {
  174. logProcess( 'Saving and committing...' );
  175. const contentStyleGuide = CONTENT_STYLES_GUIDE_PATH.replace( cwd + path.sep, '' );
  176. const contentStyleDetails = CONTENT_STYLES_DETAILS_PATH.replace( cwd + path.sep, '' );
  177. // Commit the documentation.
  178. if ( exec( `git diff --name-only ${ contentStyleGuide } ${ contentStyleDetails }` ).trim().length ) {
  179. exec( `git add ${ contentStyleGuide } ${ contentStyleDetails }` );
  180. exec( 'git commit -m "Docs (ckeditor5): Updated the content styles stylesheet."' );
  181. console.log( 'Successfully updated the content styles guide.' );
  182. } else {
  183. console.log( 'Nothing to commit. The content styles guide is up to date.' );
  184. }
  185. logProcess( 'Done.' );
  186. } );
  187. } )
  188. .catch( err => {
  189. console.log( err );
  190. } );
  191. /**
  192. * Resolves the promise with an array of paths to CKEditor 5 modules.
  193. *
  194. * @returns {Promise.<Array>}
  195. */
  196. function getCkeditor5ModulePaths() {
  197. return new Promise( ( resolve, reject ) => {
  198. glob( 'packages/*/src/**/*.js', ( err, files ) => {
  199. if ( err ) {
  200. return reject( err );
  201. }
  202. return resolve( files );
  203. } );
  204. } );
  205. }
  206. /**
  207. * Resolves the promise with a boolean value that indicates whether the module under `modulePath` is the CKEditor 5 plugin.
  208. *
  209. * @param modulePath
  210. * @returns {Promise.<Boolean>}
  211. */
  212. function checkWhetherIsCKEditor5Plugin( modulePath ) {
  213. return readFile( path.join( cwd, modulePath ) )
  214. .then( content => {
  215. const pluginName = path.basename( modulePath, '.js' );
  216. if ( content.match( new RegExp( `export default class ${ pluginName } extends Plugin`, 'i' ) ) ) {
  217. return Promise.resolve( true );
  218. }
  219. return Promise.resolve( false );
  220. } );
  221. }
  222. /**
  223. * Generates a source file that will be used to build the editor.
  224. *
  225. * @param {Array.<String>} ckeditor5Modules Paths to CKEditor 5 modules.
  226. * @returns {Promise>}
  227. */
  228. function generateCKEditor5Source( ckeditor5Modules ) {
  229. ckeditor5Modules = ckeditor5Modules.map( modulePath => {
  230. const pluginName = capitalize( path.basename( modulePath, '.js' ) );
  231. return { modulePath, pluginName };
  232. } );
  233. const sourceFileContent = [
  234. '/**',
  235. ` * @license Copyright (c) 2003-${ new Date().getFullYear() }, CKSource - Frederico Knabben. All rights reserved.`,
  236. ' * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license',
  237. ' */',
  238. '',
  239. '// The editor creator to use.',
  240. 'import ClassicEditorBase from \'@ckeditor/ckeditor5-editor-classic/src/classiceditor\';',
  241. ''
  242. ];
  243. for ( const { modulePath, pluginName } of ckeditor5Modules ) {
  244. sourceFileContent.push( `import ${ pluginName } from '${ modulePath }';` );
  245. }
  246. sourceFileContent.push( '' );
  247. sourceFileContent.push( 'export default class ClassicEditor extends ClassicEditorBase {}' );
  248. sourceFileContent.push( '' );
  249. sourceFileContent.push( '// Plugins to include in the build.' );
  250. sourceFileContent.push( 'ClassicEditor.builtinPlugins = [' );
  251. for ( const { pluginName } of ckeditor5Modules ) {
  252. sourceFileContent.push( '\t' + pluginName + ',' );
  253. }
  254. sourceFileContent.push( '];' );
  255. return writeFile( path.join( DESTINATION_DIRECTORY, 'source.js' ), sourceFileContent.join( '\n' ) )
  256. .then( () => ckeditor5Modules );
  257. function capitalize( value ) {
  258. return value.charAt( 0 ).toUpperCase() + value.slice( 1 );
  259. }
  260. }
  261. /**
  262. * Prepares the configuration for webpack.
  263. *
  264. * @returns {Object}
  265. */
  266. function getWebpackConfig() {
  267. const postCssConfig = styles.getPostCssConfig( {
  268. themeImporter: {
  269. themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
  270. },
  271. minify: false
  272. } );
  273. postCssConfig.plugins.push( postCssContentStylesPlugin( contentRules ) );
  274. return {
  275. mode: 'development',
  276. devtool: 'source-map',
  277. entry: {
  278. ckeditor5: path.join( DESTINATION_DIRECTORY, 'source.js' )
  279. },
  280. output: {
  281. path: DESTINATION_DIRECTORY,
  282. filename: '[name].js'
  283. },
  284. resolve: {
  285. modules: getModuleResolvePaths()
  286. },
  287. resolveLoader: {
  288. modules: getModuleResolvePaths()
  289. },
  290. module: {
  291. rules: [
  292. {
  293. test: /\.svg$/,
  294. use: [ 'raw-loader' ]
  295. },
  296. {
  297. test: /\.css$/,
  298. use: [
  299. 'style-loader',
  300. {
  301. loader: 'postcss-loader',
  302. options: postCssConfig
  303. }
  304. ]
  305. }
  306. ]
  307. }
  308. };
  309. }
  310. /**
  311. * Returns the PostCSS plugin that allows intercepting CSS definition used in the editor's build.
  312. *
  313. * @param {Object} contentRules
  314. * @param {Array.<String>} contentRules.variables Variables defined as `:root`.
  315. * @param {Object} contentRules.atRules Definitions of behaves.
  316. * @param {Array.<String>} contentRules.selector CSS definitions for all selectors.
  317. * @returns {Function}
  318. */
  319. function postCssContentStylesPlugin( contentRules ) {
  320. return postcss.plugin( 'list-content-styles', function() {
  321. const selectorStyles = contentRules.selector;
  322. const variables = contentRules.variables;
  323. return root => {
  324. root.walkRules( rule => {
  325. for ( const selector of rule.selectors ) {
  326. const data = {
  327. file: root.source.input.file,
  328. css: rule.toString()
  329. };
  330. if ( selector.match( ':root' ) ) {
  331. addDefinition( variables, data );
  332. }
  333. if ( selector.match( '.ck-content' ) ) {
  334. if ( rule.parent.name && rule.parent.params ) {
  335. const atRule = getAtRuleArray( contentRules.atRules, rule.parent.name, rule.parent.params );
  336. addDefinition( atRule, data );
  337. } else {
  338. addDefinition( selectorStyles, data );
  339. }
  340. }
  341. }
  342. } );
  343. };
  344. } );
  345. /**
  346. * @param {Object} collection
  347. * @param {String} name Name of an `at-rule`.
  348. * @param {String} params Parameters that describes the `at-rule`.
  349. * @returns {Array}
  350. */
  351. function getAtRuleArray( collection, name, params ) {
  352. const definition = `${ name } ${ params }`;
  353. if ( !collection[ definition ] ) {
  354. collection[ definition ] = [];
  355. }
  356. return collection[ definition ];
  357. }
  358. /**
  359. * Checks whether specified definition is duplicated in the colletion.
  360. *
  361. * @param {Array.<StyleStructure>} collection
  362. * @param {StyleStructure} def
  363. * @returns {Boolean}
  364. */
  365. function isDuplicatedDefinition( collection, def ) {
  366. for ( const item of collection ) {
  367. if ( item.file === def.file && item.css === def.css ) {
  368. return true;
  369. }
  370. }
  371. return false;
  372. }
  373. /**
  374. * Adds definition to the collection if it does not exist in the collection.
  375. *
  376. * @param {Array.<StyleStructure>} collection
  377. * @param {StyleStructure} def
  378. */
  379. function addDefinition( collection, def ) {
  380. if ( !isDuplicatedDefinition( collection, def ) ) {
  381. collection.push( def );
  382. }
  383. }
  384. }
  385. /**
  386. * @param {Object} webpackConfig
  387. * @returns {Promise}
  388. */
  389. function runWebpack( webpackConfig ) {
  390. return new Promise( ( resolve, reject ) => {
  391. webpack( webpackConfig, ( err, stats ) => {
  392. if ( err ) {
  393. reject( err );
  394. } else if ( stats.hasErrors() ) {
  395. reject( new Error( stats.toString() ) );
  396. } else {
  397. resolve();
  398. }
  399. } );
  400. } );
  401. }
  402. /**
  403. * @returns {Array.<String>}
  404. */
  405. function getModuleResolvePaths() {
  406. return [
  407. path.resolve( __dirname, '..', '..', 'node_modules' ),
  408. 'node_modules'
  409. ];
  410. }
  411. /**
  412. * Resolves the promise with the content of the file saved under the `filePath` location.
  413. *
  414. * @param {String} filePath The path to fhe file.
  415. * @returns {Promise.<String>}
  416. */
  417. function readFile( filePath ) {
  418. return new Promise( ( resolve, reject ) => {
  419. fs.readFile( filePath, 'utf-8', ( err, content ) => {
  420. if ( err ) {
  421. return reject( err );
  422. }
  423. return resolve( content );
  424. } );
  425. } );
  426. }
  427. /**
  428. * Saves the `data` value to the file saved under the `filePath` location.
  429. *
  430. * @param {String} filePath The path to fhe file.
  431. * @param {String} data The content to save.
  432. * @returns {Promise.<String>}
  433. */
  434. function writeFile( filePath, data ) {
  435. return new Promise( ( resolve, reject ) => {
  436. fs.writeFile( filePath, data, err => {
  437. if ( err ) {
  438. return reject( err );
  439. }
  440. return resolve();
  441. } );
  442. } );
  443. }
  444. /**
  445. * @param {Array} rules
  446. * @returns {String}
  447. */
  448. function transformCssRules( rules ) {
  449. return rules
  450. .map( rule => {
  451. // Removes all comments from the rule definition.
  452. const cssAsArray = rule.css.replace( /\/\*[^*]+\*\//g, '' ).split( '\n' );
  453. // We want to fix invalid indentations. We need to find a number of how many indentations we want to remove.
  454. // Because the last line ends the block, we can use this value.
  455. const lastLineIndent = cssAsArray[ cssAsArray.length - 1 ].length - 1;
  456. const css = cssAsArray
  457. .filter( line => line.trim().length > 0 )
  458. .map( ( line, index ) => {
  459. // Do not touch the first line. It is always correct.
  460. if ( index === 0 ) {
  461. return line;
  462. }
  463. const newLine = line.slice( lastLineIndent );
  464. // If a line is not a CSS definition, do not touch it.
  465. if ( !newLine.match( /[A-Z-_0-9]+:/i ) ) {
  466. return newLine;
  467. }
  468. // The line is a CSS definition – let's check whether it ends with a semicolon.
  469. if ( newLine.endsWith( ';' ) ) {
  470. return newLine;
  471. }
  472. return newLine + ';';
  473. } )
  474. .join( '\n' );
  475. return `/* ${ rule.file.replace( packagesPath + path.sep, '' ) } */\n${ css }`;
  476. } )
  477. .filter( rule => {
  478. // 1st: path to the CSS file, 2nd: selector definition - start block, 3rd: end block
  479. // If the rule contains only 3 lines, it means that it does not define any rules.
  480. return rule.split( '\n' ).length > 3;
  481. } )
  482. .join( '\n' );
  483. }
  484. /**
  485. * Returns an object that contains objects with new plugins.
  486. *
  487. * @param {Array.<Object>} currentPlugins
  488. * @param {Array.<Object>} previousPlugins
  489. * @returns {{Array.<Object>}}
  490. */
  491. function findNewPlugins( currentPlugins, previousPlugins ) {
  492. const newPlugins = [];
  493. for ( const data of currentPlugins ) {
  494. // Use relative paths.
  495. const modulePath = normalizePath( data.modulePath.replace( cwd + path.sep, '' ) );
  496. if ( !previousPlugins[ modulePath ] ) {
  497. newPlugins.push( data );
  498. }
  499. }
  500. return newPlugins;
  501. }
  502. /**
  503. * Displays a table with new plugins.
  504. *
  505. * @param {Array.<Object>} newPlugins
  506. */
  507. function displayNewPluginsTable( newPlugins ) {
  508. const table = new Table( {
  509. head: [ 'Plugin name', 'Module path' ],
  510. style: { compact: true }
  511. } );
  512. for ( const data of newPlugins ) {
  513. const modulePath = normalizePath( data.modulePath.replace( cwd + path.sep, '' ) );
  514. table.push( [ data.pluginName, modulePath ] );
  515. }
  516. console.log( table.toString() );
  517. }
  518. function normalizePath( modulePath ) {
  519. return modulePath.split( path.sep ).join( path.posix.sep );
  520. }
  521. function exec( command ) {
  522. return tools.shExec( command, { verbosity: 'error' } );
  523. }
  524. function logProcess( message ) {
  525. console.log( '\n📍 ' + chalk.cyan( message ) );
  526. }
  527. /**
  528. * @typedef {Object} StyleStructure
  529. * @property {String} file An absolute path to the file where a definition is defined.
  530. * @property {String} css Definition.
  531. */