8
0

snippetadapter.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* eslint-env node */
  6. const path = require( 'path' );
  7. const fs = require( 'fs' );
  8. const webpack = require( 'webpack' );
  9. const { bundler, styles } = require( '@ckeditor/ckeditor5-dev-utils' );
  10. const CKEditorWebpackPlugin = require( '@ckeditor/ckeditor5-dev-webpack-plugin' );
  11. const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
  12. const UglifyJsWebpackPlugin = require( 'uglifyjs-webpack-plugin' );
  13. const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
  14. const DEFAULT_LANGUAGE = 'en';
  15. /**
  16. * @param {Set.<Snippet>} snippets
  17. * @param {Object} options
  18. * @param {Object.<String, Function>} umbertoHelpers
  19. * @returns {Promise}
  20. */
  21. module.exports = function snippetAdapter( snippets, options, umbertoHelpers ) {
  22. const { getSnippetPlaceholder, getSnippetSourcePaths } = umbertoHelpers;
  23. const snippetsDependencies = new Map();
  24. // For each snippet, load its config. If the snippet has defined dependencies, load those as well.
  25. for ( const snippetData of snippets ) {
  26. if ( !snippetData.snippetSources.js ) {
  27. throw new Error( `Missing snippet source for "${ snippetData.snippetName }".` );
  28. }
  29. snippetData.snippetConfig = readSnippetConfig( snippetData.snippetSources.js );
  30. snippetData.snippetConfig.language = snippetData.snippetConfig.language || DEFAULT_LANGUAGE;
  31. if ( snippetData.snippetConfig.dependencies ) {
  32. for ( const dependencyName of snippetData.snippetConfig.dependencies ) {
  33. // Do not load the same dependency more than once.
  34. if ( snippetsDependencies.has( dependencyName ) ) {
  35. continue;
  36. }
  37. // Find a root path where to look for the snippet's sources. We just want to pass them through Webpack.
  38. const snippetBasePathRegExp = new RegExp( snippetData.snippetName.replace( /\//g, '\\/' ) + '.*$' );
  39. const snippetBasePath = snippetData.snippetSources.js.replace( snippetBasePathRegExp, '' );
  40. const dependencySnippet = {
  41. snippetSources: getSnippetSourcePaths( snippetBasePath, dependencyName ),
  42. snippetName: dependencyName,
  43. outputPath: snippetData.outputPath,
  44. destinationPath: snippetData.destinationPath,
  45. isDependency: true
  46. };
  47. if ( !dependencySnippet.snippetSources.js ) {
  48. throw new Error( `Missing snippet source for "${ dependencySnippet.snippetName }".` );
  49. }
  50. dependencySnippet.snippetConfig = readSnippetConfig( dependencySnippet.snippetSources.js );
  51. dependencySnippet.snippetConfig.language = dependencySnippet.snippetConfig.language || DEFAULT_LANGUAGE;
  52. snippetsDependencies.set( dependencyName, dependencySnippet );
  53. }
  54. }
  55. }
  56. for ( const snippetData of snippetsDependencies.values() ) {
  57. snippets.add( snippetData );
  58. }
  59. const groupedSnippetsByLanguage = {};
  60. // Group snippets by language. There is no way to build different languages in a single Webpack process.
  61. // Webpack must be called as many times as different languages are being used in snippets.
  62. for ( const snippetData of snippets ) {
  63. if ( !groupedSnippetsByLanguage[ snippetData.snippetConfig.language ] ) {
  64. groupedSnippetsByLanguage[ snippetData.snippetConfig.language ] = new Set();
  65. }
  66. groupedSnippetsByLanguage[ snippetData.snippetConfig.language ].add( snippetData );
  67. }
  68. // For each language prepare own Webpack configuration.
  69. const webpackConfigs = Object.keys( groupedSnippetsByLanguage )
  70. .map( language => {
  71. return getWebpackConfig( groupedSnippetsByLanguage[ language ], {
  72. language,
  73. production: options.production,
  74. definitions: options.definitions || {}
  75. } );
  76. } );
  77. let promise = Promise.resolve();
  78. if ( !webpackConfigs.length ) {
  79. return promise;
  80. }
  81. for ( const config of webpackConfigs ) {
  82. promise = promise.then( () => runWebpack( config ) );
  83. }
  84. return promise
  85. .then( () => {
  86. // Group snippets by destination path in order to attach required HTML code and assets (CSS and JS).
  87. const groupedSnippetsByDestinationPath = {};
  88. for ( const snippetData of snippets ) {
  89. if ( !groupedSnippetsByDestinationPath[ snippetData.destinationPath ] ) {
  90. groupedSnippetsByDestinationPath[ snippetData.destinationPath ] = new Set();
  91. }
  92. groupedSnippetsByDestinationPath[ snippetData.destinationPath ].add( snippetData );
  93. }
  94. for ( const destinationPath of Object.keys( groupedSnippetsByDestinationPath ) ) {
  95. const snippetsOnPage = groupedSnippetsByDestinationPath[ destinationPath ];
  96. const cssFiles = [];
  97. const jsFiles = [];
  98. let content = fs.readFileSync( destinationPath ).toString();
  99. for ( const snippetData of snippetsOnPage ) {
  100. // CSS may not be generated by Webpack if a snippet's JS file didn't import any CSS files.
  101. const wasCSSGenerated = fs.existsSync( path.join( snippetData.outputPath, snippetData.snippetName, 'snippet.css' ) );
  102. // If the snippet is a dependency, append JS and CSS to HTML save to disk and continue.
  103. if ( snippetData.isDependency ) {
  104. let htmlFile = fs.readFileSync( snippetData.snippetSources.html ).toString();
  105. if ( wasCSSGenerated ) {
  106. htmlFile += '<link rel="stylesheet" href="snippet.css" type="text/css">';
  107. }
  108. htmlFile += '<script src="snippet.js"></script>';
  109. fs.writeFileSync( path.join( snippetData.outputPath, snippetData.snippetName, 'snippet.html' ), htmlFile );
  110. continue;
  111. }
  112. let snippetHTML;
  113. if ( fs.existsSync( snippetData.snippetSources.html ) ) {
  114. snippetHTML = fs.readFileSync( snippetData.snippetSources.html ).toString();
  115. snippetHTML = snippetHTML.replace( /%BASE_PATH%/g, snippetData.basePath );
  116. snippetHTML = `<div class="live-snippet">${ snippetHTML }</div>`;
  117. } else {
  118. snippetHTML = '';
  119. }
  120. content = content.replace( getSnippetPlaceholder( snippetData.snippetName ), snippetHTML );
  121. jsFiles.push( path.join( snippetData.basePath, 'assets', 'snippet.js' ) );
  122. jsFiles.push( path.join( snippetData.relativeOutputPath, snippetData.snippetName, 'snippet.js' ) );
  123. cssFiles.push( path.join( snippetData.basePath, 'assets', 'snippet-styles.css' ) );
  124. if ( wasCSSGenerated ) {
  125. cssFiles.unshift( path.join( snippetData.relativeOutputPath, snippetData.snippetName, 'snippet.css' ) );
  126. }
  127. }
  128. const cssImportsHTML = [ ...new Set( cssFiles ) ]
  129. .map( importPath => ` <link rel="stylesheet" href="${ importPath }" type="text/css">` )
  130. .join( '\n' )
  131. .replace( /^\s+/, '' );
  132. const jsImportsHTML = [ ...new Set( jsFiles ) ]
  133. .map( importPath => ` <script src="${ importPath }"></script>` )
  134. .join( '\n' )
  135. .replace( /^\s+/, '' );
  136. content = content.replace( '<!--UMBERTO: SNIPPET: CSS-->', cssImportsHTML );
  137. content = content.replace( '<!--UMBERTO: SNIPPET: JS-->', jsImportsHTML );
  138. fs.writeFileSync( destinationPath, content );
  139. }
  140. } );
  141. };
  142. function getWebpackConfig( snippets, config ) {
  143. // Stringify all definitions values. The `DefinePlugin` injects definition values as they are so we need to stringify them,
  144. // so they will become real strings in the generated code. See https://webpack.js.org/plugins/define-plugin/ for more information.
  145. const definitions = {};
  146. for ( const definitionKey in config.definitions ) {
  147. definitions[ definitionKey ] = JSON.stringify( config.definitions[ definitionKey ] );
  148. }
  149. const webpackConfig = {
  150. mode: config.production ? 'production' : 'development',
  151. devtool: 'source-map',
  152. entry: {},
  153. output: {
  154. filename: '[name]/snippet.js'
  155. },
  156. optimization: {
  157. minimizer: [
  158. new UglifyJsWebpackPlugin( {
  159. sourceMap: true,
  160. uglifyOptions: {
  161. output: {
  162. // Preserve license comments starting with an exclamation mark.
  163. comments: /^!/
  164. }
  165. }
  166. } )
  167. ]
  168. },
  169. plugins: [
  170. new MiniCssExtractPlugin( { filename: '[name]/snippet.css' } ),
  171. new CKEditorWebpackPlugin( {
  172. language: config.language
  173. } ),
  174. new webpack.BannerPlugin( {
  175. banner: bundler.getLicenseBanner(),
  176. raw: true
  177. } ),
  178. new webpack.DefinePlugin( definitions ),
  179. new ProgressBarPlugin( {
  180. format: `Building snippets for language "${ config.language }": :percent (:msg)`,
  181. } )
  182. ],
  183. // Configure the paths so building CKEditor 5 snippets work even if the script
  184. // is triggered from a directory outside ckeditor5 (e.g. multi-project case).
  185. resolve: {
  186. modules: getModuleResolvePaths()
  187. },
  188. resolveLoader: {
  189. modules: getModuleResolvePaths()
  190. },
  191. module: {
  192. rules: [
  193. {
  194. test: /\.svg$/,
  195. use: [ 'raw-loader' ]
  196. },
  197. {
  198. test: /\.css$/,
  199. use: [
  200. MiniCssExtractPlugin.loader,
  201. 'css-loader',
  202. {
  203. loader: 'postcss-loader',
  204. options: styles.getPostCssConfig( {
  205. themeImporter: {
  206. themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
  207. },
  208. minify: config.production
  209. } )
  210. }
  211. ]
  212. }
  213. ]
  214. }
  215. };
  216. for ( const snippetData of snippets ) {
  217. if ( !webpackConfig.output.path ) {
  218. webpackConfig.output.path = snippetData.outputPath;
  219. }
  220. if ( webpackConfig.entry[ snippetData.snippetName ] ) {
  221. continue;
  222. }
  223. webpackConfig.entry[ snippetData.snippetName ] = snippetData.snippetSources.js;
  224. }
  225. return webpackConfig;
  226. }
  227. function runWebpack( webpackConfig ) {
  228. return new Promise( ( resolve, reject ) => {
  229. webpack( webpackConfig, ( err, stats ) => {
  230. if ( err ) {
  231. reject( err );
  232. } else if ( stats.hasErrors() ) {
  233. reject( new Error( stats.toString() ) );
  234. } else {
  235. resolve();
  236. }
  237. } );
  238. } );
  239. }
  240. function getModuleResolvePaths() {
  241. return [
  242. path.resolve( __dirname, '..', '..', 'node_modules' ),
  243. 'node_modules'
  244. ];
  245. }
  246. function readSnippetConfig( snippetSourcePath ) {
  247. const snippetSource = fs.readFileSync( snippetSourcePath ).toString();
  248. const configSourceMatch = snippetSource.match( /\n\/\* config ([\s\S]+?)\*\// );
  249. if ( !configSourceMatch ) {
  250. return {};
  251. }
  252. return JSON.parse( configSourceMatch[ 1 ] );
  253. }
  254. /**
  255. * @typedef {Object} Snippet
  256. *
  257. * @property {SnippetSource} snippetSources Sources of the snippet.
  258. *
  259. * @property {String} snippetName Name of the snippet. Defined directly after `@snippet` tag.
  260. *
  261. * @property {String} outputPath An absolute path where to write file produced by the `snippetAdapter`.
  262. *
  263. * @property {String} destinationPath An absolute path to the file where the snippet is being used.
  264. *
  265. * @property {SnippetConfiguration} snippetConfig={} Additional configuration of the snippet. It's being read from the snippet's source.
  266. *
  267. * @property {String} [basePath] Relative path from the processed file to the root of the documentation.
  268. *
  269. * @property {String} [relativeOutputPath] The same like `basePath` but for the output path (where processed file will be saved).
  270. *
  271. * @property {Boolean} [isDependency] Whether parsed snippet is a dependency of other snippet.
  272. */
  273. /**
  274. * @typedef {Object} SnippetSource
  275. *
  276. * @property {<String>} html An absolute path to the HTML sample.
  277. *
  278. * @property {<String>} css An absolute path to the CSS sample.
  279. *
  280. * @property {<String>} js An absolute path to the JS sample.
  281. */
  282. /**
  283. * @typedef {Object} SnippetConfiguration
  284. *
  285. * @property {<String>} [language] A language that will be used for building the editor.
  286. *
  287. * @property {Array.<String>} [dependencies] Names of samples that are required to working.
  288. */