scaffold.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. /* eslint-disable no-console */
  2. import { spawn } from 'child_process';
  3. import * as fs from 'fs-extra';
  4. import { globSync } from 'glob';
  5. import * as path from 'path';
  6. import {
  7. EXTENSION_ROUTES_FILE,
  8. GLOBAL_STYLES_OUTPUT_DIR,
  9. MODULES_OUTPUT_DIR,
  10. SHARED_EXTENSIONS_FILE,
  11. } from './constants';
  12. import { getAllTranslationFiles, mergeExtensionTranslations } from './translations';
  13. import {
  14. AdminUiExtensionLazyModule,
  15. AdminUiExtensionSharedModule,
  16. AdminUiExtensionWithId,
  17. Extension,
  18. GlobalStylesExtension,
  19. SassVariableOverridesExtension,
  20. StaticAssetExtension,
  21. } from './types';
  22. import {
  23. copyStaticAsset,
  24. copyUiDevkit,
  25. isAdminUiExtension,
  26. isGlobalStylesExtension,
  27. isSassVariableOverridesExtension,
  28. isStaticAssetExtension,
  29. isTranslationExtension,
  30. logger,
  31. normalizeExtensions,
  32. shouldUseYarn,
  33. } from './utils';
  34. export async function setupScaffold(outputPath: string, extensions: Extension[]) {
  35. deleteExistingExtensionModules(outputPath);
  36. const adminUiExtensions = extensions.filter(isAdminUiExtension);
  37. const normalizedExtensions = normalizeExtensions(adminUiExtensions);
  38. const modulePathMapping = generateModulePathMapping(normalizedExtensions);
  39. copyAdminUiSource(outputPath, modulePathMapping);
  40. await copyExtensionModules(outputPath, normalizedExtensions);
  41. const staticAssetExtensions = extensions.filter(isStaticAssetExtension);
  42. await copyStaticAssets(outputPath, staticAssetExtensions);
  43. const globalStyleExtensions = extensions.filter(isGlobalStylesExtension);
  44. const sassVariableOverridesExtension = extensions.find(isSassVariableOverridesExtension);
  45. await addGlobalStyles(outputPath, globalStyleExtensions, sassVariableOverridesExtension);
  46. const allTranslationFiles = getAllTranslationFiles(extensions.filter(isTranslationExtension));
  47. await mergeExtensionTranslations(outputPath, allTranslationFiles);
  48. copyUiDevkit(outputPath);
  49. try {
  50. await checkIfNgccWasRun();
  51. } catch (e: any) {
  52. const cmd = shouldUseYarn() ? 'yarn ngcc' : 'npx ngcc';
  53. logger.log(
  54. `An error occurred when running ngcc. Try removing node_modules, re-installing, and then manually running "${cmd}" in the project root.`,
  55. );
  56. }
  57. }
  58. /**
  59. * Deletes the contents of the /modules directory, which contains the plugin
  60. * extension modules copied over during the last compilation.
  61. */
  62. function deleteExistingExtensionModules(outputPath: string) {
  63. fs.removeSync(path.join(outputPath, MODULES_OUTPUT_DIR));
  64. }
  65. /**
  66. * Generates a module path mapping object for all extensions with a "pathAlias"
  67. * property declared (if any).
  68. */
  69. function generateModulePathMapping(extensions: AdminUiExtensionWithId[]) {
  70. const extensionsWithAlias = extensions.filter(e => e.pathAlias);
  71. if (extensionsWithAlias.length === 0) {
  72. return undefined;
  73. }
  74. return extensionsWithAlias.reduce((acc, e) => {
  75. // for imports from the index file if there is one
  76. acc[e.pathAlias as string] = [`src/extensions/${e.id}`];
  77. // direct access to files / deep imports
  78. acc[`${e.pathAlias as string}/*`] = [`src/extensions/${e.id}/*`];
  79. return acc;
  80. }, {} as Record<string, string[]>);
  81. }
  82. /**
  83. * Copies all files from the extensionPaths of the configured extensions into the
  84. * admin-ui source tree.
  85. */
  86. async function copyExtensionModules(outputPath: string, extensions: AdminUiExtensionWithId[]) {
  87. const extensionRoutesSource = generateLazyExtensionRoutes(extensions);
  88. fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8');
  89. const sharedExtensionModulesSource = generateSharedExtensionModule(extensions);
  90. fs.writeFileSync(path.join(outputPath, SHARED_EXTENSIONS_FILE), sharedExtensionModulesSource, 'utf8');
  91. for (const extension of extensions) {
  92. const dest = path.join(outputPath, MODULES_OUTPUT_DIR, extension.id);
  93. if (!extension.exclude) {
  94. fs.copySync(extension.extensionPath, dest);
  95. continue;
  96. }
  97. const exclude = extension.exclude
  98. .map(e => globSync(path.join(extension.extensionPath, e)))
  99. .flatMap(e => e);
  100. fs.copySync(extension.extensionPath, dest, {
  101. filter: name => name === extension.extensionPath || exclude.every(e => e !== name),
  102. });
  103. }
  104. }
  105. async function copyStaticAssets(outputPath: string, extensions: Array<Partial<StaticAssetExtension>>) {
  106. for (const extension of extensions) {
  107. if (Array.isArray(extension.staticAssets)) {
  108. for (const asset of extension.staticAssets) {
  109. await copyStaticAsset(outputPath, asset);
  110. }
  111. }
  112. }
  113. }
  114. async function addGlobalStyles(
  115. outputPath: string,
  116. globalStylesExtensions: GlobalStylesExtension[],
  117. sassVariableOverridesExtension?: SassVariableOverridesExtension,
  118. ) {
  119. const globalStylesDir = path.join(outputPath, 'src', GLOBAL_STYLES_OUTPUT_DIR);
  120. await fs.remove(globalStylesDir);
  121. await fs.ensureDir(globalStylesDir);
  122. const imports: string[] = [];
  123. for (const extension of globalStylesExtensions) {
  124. const styleFiles = Array.isArray(extension.globalStyles)
  125. ? extension.globalStyles
  126. : [extension.globalStyles];
  127. for (const styleFile of styleFiles) {
  128. await copyGlobalStyleFile(outputPath, styleFile);
  129. imports.push(path.basename(styleFile, path.extname(styleFile)));
  130. }
  131. }
  132. let overridesImport = '';
  133. if (sassVariableOverridesExtension) {
  134. const overridesFile = sassVariableOverridesExtension.sassVariableOverrides;
  135. await copyGlobalStyleFile(outputPath, overridesFile);
  136. overridesImport = `@import "./${GLOBAL_STYLES_OUTPUT_DIR}/${path.basename(
  137. overridesFile,
  138. path.extname(overridesFile),
  139. )}";\n`;
  140. }
  141. const globalStylesSource =
  142. overridesImport +
  143. '@import "./styles/styles";\n' +
  144. imports.map(file => `@import "./${GLOBAL_STYLES_OUTPUT_DIR}/${file}";`).join('\n');
  145. const globalStylesFile = path.join(outputPath, 'src', 'global-styles.scss');
  146. await fs.writeFile(globalStylesFile, globalStylesSource, 'utf-8');
  147. }
  148. export async function copyGlobalStyleFile(outputPath: string, stylePath: string) {
  149. const globalStylesDir = path.join(outputPath, 'src', GLOBAL_STYLES_OUTPUT_DIR);
  150. const fileBasename = path.basename(stylePath);
  151. const styleOutputPath = path.join(globalStylesDir, fileBasename);
  152. await fs.copyFile(stylePath, styleOutputPath);
  153. }
  154. function generateLazyExtensionRoutes(extensions: AdminUiExtensionWithId[]): string {
  155. const routes: string[] = [];
  156. for (const extension of extensions) {
  157. for (const module of extension.ngModules) {
  158. if (module.type === 'lazy') {
  159. routes.push(` {
  160. path: 'extensions/${module.route}',
  161. loadChildren: () => import('${getModuleFilePath(extension.id, module)}').then(m => m.${
  162. module.ngModuleName
  163. }),
  164. }`);
  165. }
  166. }
  167. }
  168. return `export const extensionRoutes = [${routes.join(',\n')}];\n`;
  169. }
  170. function generateSharedExtensionModule(extensions: AdminUiExtensionWithId[]) {
  171. return `import { NgModule } from '@angular/core';
  172. import { CommonModule } from '@angular/common';
  173. ${extensions
  174. .map(e =>
  175. e.ngModules
  176. .filter(m => m.type === 'shared')
  177. .map(m => `import { ${m.ngModuleName} } from '${getModuleFilePath(e.id, m)}';\n`)
  178. .join(''),
  179. )
  180. .join('')}
  181. @NgModule({
  182. imports: [CommonModule, ${extensions
  183. .map(e =>
  184. e.ngModules
  185. .filter(m => m.type === 'shared')
  186. .map(m => m.ngModuleName)
  187. .join(', '),
  188. )
  189. .join(', ')}],
  190. })
  191. export class SharedExtensionsModule {}
  192. `;
  193. }
  194. function getModuleFilePath(
  195. id: string,
  196. module: AdminUiExtensionLazyModule | AdminUiExtensionSharedModule,
  197. ): string {
  198. return `./extensions/${id}/${path.basename(module.ngModuleFileName, '.ts')}`;
  199. }
  200. /**
  201. * Copies the Admin UI sources & static assets to the outputPath if it does not already
  202. * exist there.
  203. */
  204. function copyAdminUiSource(outputPath: string, modulePathMapping: Record<string, string[]> | undefined) {
  205. const tsconfigFilePath = path.join(outputPath, 'tsconfig.json');
  206. const indexFilePath = path.join(outputPath, '/src/index.html');
  207. if (fs.existsSync(tsconfigFilePath) && fs.existsSync(indexFilePath)) {
  208. configureModulePathMapping(tsconfigFilePath, modulePathMapping);
  209. return;
  210. }
  211. const scaffoldDir = path.join(__dirname, '../scaffold');
  212. const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static');
  213. if (!fs.existsSync(scaffoldDir)) {
  214. throw new Error(`Could not find the admin ui scaffold files at ${scaffoldDir}`);
  215. }
  216. if (!fs.existsSync(adminUiSrc)) {
  217. throw new Error(`Could not find the @vendure/admin-ui sources. Looked in ${adminUiSrc}`);
  218. }
  219. // copy scaffold
  220. fs.removeSync(outputPath);
  221. fs.ensureDirSync(outputPath);
  222. fs.copySync(scaffoldDir, outputPath);
  223. configureModulePathMapping(tsconfigFilePath, modulePathMapping);
  224. // copy source files from admin-ui package
  225. const outputSrc = path.join(outputPath, 'src');
  226. fs.ensureDirSync(outputSrc);
  227. fs.copySync(adminUiSrc, outputSrc);
  228. }
  229. export async function setBaseHref(outputPath: string, baseHref: string) {
  230. const angularJsonFilePath = path.join(outputPath, '/angular.json');
  231. const angularJson = await fs.readJSON(angularJsonFilePath, 'utf-8');
  232. angularJson.projects['vendure-admin'].architect.build.options.baseHref = baseHref;
  233. await fs.writeJSON(angularJsonFilePath, angularJson, { spaces: 2 });
  234. }
  235. /**
  236. * Adds module path mapping to the bundled tsconfig.json file if defined as a UI extension.
  237. */
  238. function configureModulePathMapping(
  239. tsconfigFilePath: string,
  240. modulePathMapping: Record<string, string[]> | undefined,
  241. ) {
  242. if (!modulePathMapping) {
  243. return;
  244. }
  245. // eslint-disable-next-line @typescript-eslint/no-var-requires
  246. const tsconfig = require(tsconfigFilePath);
  247. tsconfig.compilerOptions.paths = modulePathMapping;
  248. fs.writeFileSync(tsconfigFilePath, JSON.stringify(tsconfig, null, 2));
  249. }
  250. /**
  251. * Attempts to find out it the ngcc compiler has been run on the Angular packages, and if not,
  252. * attemps to run it. This is done this way because attempting to run ngcc from a sub-directory
  253. * where the angular libs are in a higher-level node_modules folder currently results in the error
  254. * NG6002, see https://github.com/angular/angular/issues/35747.
  255. *
  256. * However, when ngcc is run from the root, it works.
  257. */
  258. async function checkIfNgccWasRun(): Promise<void> {
  259. const coreUmdFile = require.resolve('@vendure/admin-ui/core');
  260. if (!coreUmdFile) {
  261. logger.error('Could not resolve the "@vendure/admin-ui/core" package!');
  262. return;
  263. }
  264. // ngcc creates a particular folder after it has been run once
  265. const ivyDir = path.join(coreUmdFile, '../..', '__ivy_ngcc__');
  266. if (fs.existsSync(ivyDir)) {
  267. return;
  268. }
  269. // Looks like ngcc has not been run, so attempt to do so.
  270. const rootDir = coreUmdFile.split('node_modules')[0];
  271. return new Promise((resolve, reject) => {
  272. logger.log(
  273. 'Running the Angular Ivy compatibility compiler (ngcc) on Vendure Admin UI dependencies ' +
  274. '(this is only needed on the first run)...',
  275. );
  276. const cmd = shouldUseYarn() ? 'yarn' : 'npx';
  277. const ngccProcess = spawn(
  278. cmd,
  279. [
  280. 'ngcc',
  281. '--properties es2015 browser module main',
  282. '--first-only',
  283. '--create-ivy-entry-points',
  284. '-l=error',
  285. ],
  286. {
  287. cwd: rootDir,
  288. shell: true,
  289. stdio: 'inherit',
  290. },
  291. );
  292. ngccProcess.on('close', code => {
  293. if (code !== 0) {
  294. reject(code);
  295. } else {
  296. resolve();
  297. }
  298. });
  299. });
  300. }