8
0

build-content-styles.js 15 KB

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