snippetadapter.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. /**
  2. * @license Copyright (c) 2003-2019, 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 minimatch = require( 'minimatch' );
  9. const webpack = require( 'webpack' );
  10. const { bundler, styles } = require( '@ckeditor/ckeditor5-dev-utils' );
  11. const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin' );
  12. const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
  13. const UglifyJsWebpackPlugin = require( 'uglifyjs-webpack-plugin' );
  14. const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
  15. const DEFAULT_LANGUAGE = 'en';
  16. /**
  17. * @param {Set.<Snippet>} snippets Snippet collection extracted from documentation files.
  18. * @param {Object} options
  19. * @param {Boolean} options.production Whether to build snippets in production mode.
  20. * @param {Array.<String>>|undefined} options.whitelistedSnippets An array that contains glob patterns.
  21. * @param {Object.<String, Function>} umbertoHelpers
  22. * @returns {Promise}
  23. */
  24. module.exports = function snippetAdapter( snippets, options, umbertoHelpers ) {
  25. const { getSnippetPlaceholder, getSnippetSourcePaths } = umbertoHelpers;
  26. const snippetsDependencies = new Map();
  27. // For each snippet, load its config. If the snippet has defined dependencies, load those as well.
  28. for ( const snippetData of snippets ) {
  29. if ( !snippetData.snippetSources.js ) {
  30. throw new Error( `Missing snippet source for "${ snippetData.snippetName }".` );
  31. }
  32. snippetData.snippetConfig = readSnippetConfig( snippetData.snippetSources.js );
  33. snippetData.snippetConfig.language = snippetData.snippetConfig.language || DEFAULT_LANGUAGE;
  34. if ( snippetData.snippetConfig.dependencies ) {
  35. for ( const dependencyName of snippetData.snippetConfig.dependencies ) {
  36. // Do not load the same dependency more than once.
  37. if ( snippetsDependencies.has( dependencyName ) ) {
  38. continue;
  39. }
  40. // Find a root path where to look for the snippet's sources. We just want to pass it through Webpack.
  41. const snippetBasePathRegExp = new RegExp( snippetData.snippetName.replace( /\//g, '\\/' ) + '.*$' );
  42. const snippetBasePath = snippetData.snippetSources.js.replace( snippetBasePathRegExp, '' );
  43. const dependencySnippet = {
  44. snippetSources: getSnippetSourcePaths( snippetBasePath, dependencyName ),
  45. snippetName: dependencyName,
  46. outputPath: snippetData.outputPath,
  47. destinationPath: snippetData.destinationPath,
  48. requiredFor: snippetData
  49. };
  50. if ( !dependencySnippet.snippetSources.js ) {
  51. throw new Error( `Missing snippet source for "${ dependencySnippet.snippetName }".` );
  52. }
  53. dependencySnippet.snippetConfig = readSnippetConfig( dependencySnippet.snippetSources.js );
  54. dependencySnippet.snippetConfig.language = dependencySnippet.snippetConfig.language || DEFAULT_LANGUAGE;
  55. snippetsDependencies.set( dependencyName, dependencySnippet );
  56. }
  57. }
  58. }
  59. // Add all dependencies to the snippet collection.
  60. for ( const snippetData of snippetsDependencies.values() ) {
  61. snippets.add( snippetData );
  62. }
  63. // Remove snippets that do not match to patterns specified in `options.whitelistedSnippets`.
  64. if ( options.whitelistedSnippets ) {
  65. filterWhitelistedSnippets( snippets, options.whitelistedSnippets );
  66. }
  67. console.log( 'Number of snippets that will be built:', snippets.size );
  68. const groupedSnippetsByLanguage = {};
  69. // Group snippets by language. There is no way to build different languages in a single Webpack process.
  70. // Webpack must be called as many times as different languages are being used in snippets.
  71. for ( const snippetData of snippets ) {
  72. if ( !groupedSnippetsByLanguage[ snippetData.snippetConfig.language ] ) {
  73. groupedSnippetsByLanguage[ snippetData.snippetConfig.language ] = new Set();
  74. }
  75. groupedSnippetsByLanguage[ snippetData.snippetConfig.language ].add( snippetData );
  76. }
  77. // For each language prepare own Webpack configuration.
  78. const webpackConfigs = Object.keys( groupedSnippetsByLanguage )
  79. .map( language => {
  80. return getWebpackConfig( groupedSnippetsByLanguage[ language ], {
  81. language,
  82. production: options.production,
  83. definitions: options.definitions || {}
  84. } );
  85. } );
  86. let promise = Promise.resolve();
  87. // Nothing to build.
  88. if ( !webpackConfigs.length ) {
  89. return promise;
  90. }
  91. for ( const config of webpackConfigs ) {
  92. promise = promise.then( () => runWebpack( config ) );
  93. }
  94. return promise
  95. .then( () => {
  96. // Group snippets by destination path in order to attach required HTML code and assets (CSS and JS).
  97. const groupedSnippetsByDestinationPath = {};
  98. for ( const snippetData of snippets ) {
  99. if ( !groupedSnippetsByDestinationPath[ snippetData.destinationPath ] ) {
  100. groupedSnippetsByDestinationPath[ snippetData.destinationPath ] = new Set();
  101. }
  102. groupedSnippetsByDestinationPath[ snippetData.destinationPath ].add( snippetData );
  103. }
  104. // For every page that contains at least one snippet, we need to replace Umberto comments with HTML code.
  105. for ( const destinationPath of Object.keys( groupedSnippetsByDestinationPath ) ) {
  106. const snippetsOnPage = groupedSnippetsByDestinationPath[ destinationPath ];
  107. // Assets required for the all snippets.
  108. const cssFiles = [];
  109. const jsFiles = [];
  110. let content = fs.readFileSync( destinationPath ).toString();
  111. for ( const snippetData of snippetsOnPage ) {
  112. // CSS may not be generated by Webpack if a snippet's JS file didn't import any CSS files.
  113. const wasCSSGenerated = fs.existsSync( path.join( snippetData.outputPath, snippetData.snippetName, 'snippet.css' ) );
  114. // If the snippet is a dependency, append JS and CSS to HTML, save to disk and continue.
  115. if ( snippetData.requiredFor ) {
  116. let htmlFile = fs.readFileSync( snippetData.snippetSources.html ).toString();
  117. if ( wasCSSGenerated ) {
  118. htmlFile += '<link rel="stylesheet" href="snippet.css" type="text/css">';
  119. }
  120. htmlFile += '<script src="snippet.js"></script>';
  121. fs.writeFileSync( path.join( snippetData.outputPath, snippetData.snippetName, 'snippet.html' ), htmlFile );
  122. continue;
  123. }
  124. let snippetHTML = fs.readFileSync( snippetData.snippetSources.html ).toString();
  125. snippetHTML = snippetHTML.replace( /%BASE_PATH%/g, snippetData.basePath );
  126. snippetHTML = `<div class="live-snippet">${ snippetHTML }</div>`;
  127. content = content.replace( getSnippetPlaceholder( snippetData.snippetName ), snippetHTML );
  128. jsFiles.push( path.join( snippetData.basePath, 'assets', 'snippet.js' ) );
  129. jsFiles.push( path.join( snippetData.relativeOutputPath, snippetData.snippetName, 'snippet.js' ) );
  130. cssFiles.push( path.join( snippetData.basePath, 'assets', 'snippet-styles.css' ) );
  131. if ( wasCSSGenerated ) {
  132. cssFiles.unshift( path.join( snippetData.relativeOutputPath, snippetData.snippetName, 'snippet.css' ) );
  133. }
  134. }
  135. const cssImportsHTML = getHTMLImports( cssFiles, importPath => {
  136. return ` <link rel="stylesheet" href="${ importPath }" type="text/css">`;
  137. } );
  138. const jsImportsHTML = getHTMLImports( jsFiles, importPath => {
  139. return ` <script src="${ importPath }"></script>`;
  140. } );
  141. content = content.replace( '<!--UMBERTO: SNIPPET: CSS-->', cssImportsHTML );
  142. content = content.replace( '<!--UMBERTO: SNIPPET: JS-->', jsImportsHTML );
  143. fs.writeFileSync( destinationPath, content );
  144. }
  145. } )
  146. .then( () => {
  147. console.log( 'Snippets has been built.' );
  148. } );
  149. };
  150. /**
  151. * Removes snippets that names do not match to patterns specified in `whitelistedSnippets` array.
  152. *
  153. * @param {Set.<Snippet>} snippets Snippet collection extracted from documentation files.
  154. * @param {Array.<String>|undefined} whitelistedSnippets Snippet patterns that should be built.
  155. */
  156. function filterWhitelistedSnippets( snippets, whitelistedSnippets ) {
  157. if ( !whitelistedSnippets.length ) {
  158. return;
  159. }
  160. const snippetsToBuild = new Set();
  161. // Find all snippets that matched to specified criteria.
  162. for ( const snippetData of snippets ) {
  163. const matchToPatterns = whitelistedSnippets.some( pattern => minimatch( snippetData.snippetName, pattern ) );
  164. // Snippet should be built.
  165. if ( matchToPatterns ) {
  166. snippetsToBuild.add( snippetData );
  167. }
  168. }
  169. // Find all dependencies that are required for whitelisted snippets.
  170. for ( const snippetData of snippets ) {
  171. if ( snippetsToBuild.has( snippetData ) ) {
  172. continue;
  173. }
  174. if ( snippetData.requiredFor && snippetsToBuild.has( snippetData.requiredFor ) ) {
  175. snippetsToBuild.add( snippetData );
  176. }
  177. }
  178. // Remove snippets that won't be built and aren't dependencies of other snippets.
  179. for ( const snippetData of snippets ) {
  180. if ( !snippetsToBuild.has( snippetData ) ) {
  181. snippets.delete( snippetData );
  182. }
  183. }
  184. }
  185. /**
  186. * Prepares configuration for Webpack.
  187. *
  188. * @param {Set.<Snippet>} snippets Snippet collection extracted from documentation files.
  189. * @param {Object} config
  190. * @param {String} config.language Language for the build.
  191. * @param {Boolean} config.production Whether to build for production.
  192. * @param {Object} config.definitions
  193. * @returns {Object}
  194. */
  195. function getWebpackConfig( snippets, config ) {
  196. // Stringify all definitions values. The `DefinePlugin` injects definition values as they are so we need to stringify them,
  197. // so they will become real strings in the generated code. See https://webpack.js.org/plugins/define-plugin/ for more information.
  198. const definitions = {};
  199. for ( const definitionKey in config.definitions ) {
  200. definitions[ definitionKey ] = JSON.stringify( config.definitions[ definitionKey ] );
  201. }
  202. const webpackConfig = {
  203. mode: config.production ? 'production' : 'development',
  204. devtool: 'source-map',
  205. entry: {},
  206. output: {
  207. filename: '[name]/snippet.js'
  208. },
  209. optimization: {
  210. minimizer: [
  211. new UglifyJsWebpackPlugin( {
  212. sourceMap: true,
  213. uglifyOptions: {
  214. output: {
  215. // Preserve license comments starting with an exclamation mark.
  216. comments: /^!/
  217. }
  218. }
  219. } )
  220. ]
  221. },
  222. plugins: [
  223. new MiniCssExtractPlugin( { filename: '[name]/snippet.css' } ),
  224. new CKEditorWebpackPlugin( {
  225. language: config.language
  226. } ),
  227. new webpack.BannerPlugin( {
  228. banner: bundler.getLicenseBanner(),
  229. raw: true
  230. } ),
  231. new webpack.DefinePlugin( definitions ),
  232. new ProgressBarPlugin( {
  233. format: `Building snippets for language "${ config.language }": :percent (:msg)`,
  234. } )
  235. ],
  236. // Configure the paths so building CKEditor 5 snippets work even if the script
  237. // is triggered from a directory outside ckeditor5 (e.g. multi-project case).
  238. resolve: {
  239. modules: getModuleResolvePaths()
  240. },
  241. resolveLoader: {
  242. modules: getModuleResolvePaths()
  243. },
  244. module: {
  245. rules: [
  246. {
  247. test: /\.svg$/,
  248. use: [ 'raw-loader' ]
  249. },
  250. {
  251. test: /\.css$/,
  252. use: [
  253. MiniCssExtractPlugin.loader,
  254. 'css-loader',
  255. {
  256. loader: 'postcss-loader',
  257. options: styles.getPostCssConfig( {
  258. themeImporter: {
  259. themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
  260. },
  261. minify: config.production
  262. } )
  263. }
  264. ]
  265. }
  266. ]
  267. }
  268. };
  269. for ( const snippetData of snippets ) {
  270. if ( !webpackConfig.output.path ) {
  271. webpackConfig.output.path = snippetData.outputPath;
  272. }
  273. if ( webpackConfig.entry[ snippetData.snippetName ] ) {
  274. continue;
  275. }
  276. webpackConfig.entry[ snippetData.snippetName ] = snippetData.snippetSources.js;
  277. }
  278. return webpackConfig;
  279. }
  280. /**
  281. * Builds snippets.
  282. *
  283. * @param {Object} webpackConfig
  284. * @returns {Promise}
  285. */
  286. function runWebpack( webpackConfig ) {
  287. return new Promise( ( resolve, reject ) => {
  288. webpack( webpackConfig, ( err, stats ) => {
  289. if ( err ) {
  290. reject( err );
  291. } else if ( stats.hasErrors() ) {
  292. reject( new Error( stats.toString() ) );
  293. } else {
  294. resolve();
  295. }
  296. } );
  297. } );
  298. }
  299. /**
  300. * @returns {Array.<String>}
  301. */
  302. function getModuleResolvePaths() {
  303. return [
  304. path.resolve( __dirname, '..', '..', 'node_modules' ),
  305. 'node_modules'
  306. ];
  307. }
  308. /**
  309. * Reads the snippet's configuration.
  310. *
  311. * @param {String} snippetSourcePath An absolute path to the file.
  312. * @returns {Object}
  313. */
  314. function readSnippetConfig( snippetSourcePath ) {
  315. const snippetSource = fs.readFileSync( snippetSourcePath ).toString();
  316. const configSourceMatch = snippetSource.match( /\n\/\* config ([\s\S]+?)\*\// );
  317. if ( !configSourceMatch ) {
  318. return {};
  319. }
  320. return JSON.parse( configSourceMatch[ 1 ] );
  321. }
  322. /**
  323. * Removes duplicated entries specified in `files` array and map those entires using `mapFunction`.
  324. *
  325. * @param {Array.<String>} files Paths collection.
  326. * @param {Function} mapFunction Function that should return a string.
  327. * @returns {String}
  328. */
  329. function getHTMLImports( files, mapFunction ) {
  330. return [ ...new Set( files ) ]
  331. .map( mapFunction )
  332. .join( '\n' )
  333. .replace( /^\s+/, '' );
  334. }
  335. /**
  336. * @typedef {Object} Snippet
  337. *
  338. * @property {SnippetSource} snippetSources Sources of the snippet.
  339. *
  340. * @property {String} snippetName Name of the snippet. Defined directly after `@snippet` tag.
  341. *
  342. * @property {String} outputPath An absolute path where to write file produced by the `snippetAdapter`.
  343. *
  344. * @property {String} destinationPath An absolute path to the file where the snippet is being used.
  345. *
  346. * @property {SnippetConfiguration} snippetConfig={} Additional configuration of the snippet. It's being read from the snippet's source.
  347. *
  348. * @property {String} [basePath] Relative path from the processed file to the root of the documentation.
  349. *
  350. * @property {String} [relativeOutputPath] The same like `basePath` but for the output path (where processed file will be saved).
  351. *
  352. * @property {Snippet|undefined} [requiredFor] If the value is instance of `Snippet`, current snippet requires
  353. * the snippet defined as `requiredFor` to work.
  354. */
  355. /**
  356. * @typedef {Object} SnippetSource
  357. *
  358. * @property {String} html An absolute path to the HTML sample.
  359. *
  360. * @property {String} css An absolute path to the CSS sample.
  361. *
  362. * @property {String} js An absolute path to the JS sample.
  363. */
  364. /**
  365. * @typedef {Object} SnippetConfiguration
  366. *
  367. * @property {String} [language] A language that will be used for building the editor.
  368. *
  369. * @property {Array.<String>} [dependencies] Names of samples that are required to working.
  370. */