snippetadapter.js 17 KB

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