Просмотр исходного кода

feat(ui-devkit): Support module path mappings for UI extensions (#1994)

Vinicius Rosa 3 лет назад
Родитель
Сommit
6d57c8611d

+ 53 - 13
packages/ui-devkit/src/compiler/scaffold.ts

@@ -8,13 +8,12 @@ import {
     GLOBAL_STYLES_OUTPUT_DIR,
     MODULES_OUTPUT_DIR,
     SHARED_EXTENSIONS_FILE,
-    STATIC_ASSETS_OUTPUT_DIR,
 } from './constants';
 import { getAllTranslationFiles, mergeExtensionTranslations } from './translations';
 import {
-    AdminUiExtension,
     AdminUiExtensionLazyModule,
     AdminUiExtensionSharedModule,
+    AdminUiExtensionWithId,
     Extension,
     GlobalStylesExtension,
     SassVariableOverridesExtension,
@@ -35,10 +34,13 @@ import {
 
 export async function setupScaffold(outputPath: string, extensions: Extension[]) {
     deleteExistingExtensionModules(outputPath);
-    copyAdminUiSource(outputPath);
 
     const adminUiExtensions = extensions.filter(isAdminUiExtension);
     const normalizedExtensions = normalizeExtensions(adminUiExtensions);
+
+    const modulePathMapping = generateModulePathMapping(normalizedExtensions);
+    copyAdminUiSource(outputPath, modulePathMapping);
+
     await copyExtensionModules(outputPath, normalizedExtensions);
 
     const staticAssetExtensions = extensions.filter(isStaticAssetExtension);
@@ -70,11 +72,30 @@ function deleteExistingExtensionModules(outputPath: string) {
     fs.removeSync(path.join(outputPath, MODULES_OUTPUT_DIR));
 }
 
+/**
+ * Generates a module path mapping object for all extensions with a "pathAlias"
+ * property declared (if any).
+ */
+function generateModulePathMapping(extensions: Array<AdminUiExtensionWithId>) {
+    const extensionsWithAlias = extensions.filter(e => e.pathAlias);
+    if (extensionsWithAlias.length === 0) {
+        return undefined;
+    }
+
+    return extensionsWithAlias.reduce((acc, e) => {
+        // for imports from the index file if there is one
+        acc[e.pathAlias as string] = [`src/extensions/${e.id}`];
+        // direct access to files / deep imports
+        acc[`${e.pathAlias as string}/*`] = [`src/extensions/${e.id}/*`];
+        return acc;
+    }, {} as Record<string, string[]>);
+}
+
 /**
  * Copies all files from the extensionPaths of the configured extensions into the
  * admin-ui source tree.
  */
-async function copyExtensionModules(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
+async function copyExtensionModules(outputPath: string, extensions: Array<AdminUiExtensionWithId>) {
     const extensionRoutesSource = generateLazyExtensionRoutes(extensions);
     fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8');
     const sharedExtensionModulesSource = generateSharedExtensionModule(extensions);
@@ -142,9 +163,9 @@ export async function copyGlobalStyleFile(outputPath: string, stylePath: string)
     await fs.copyFile(stylePath, styleOutputPath);
 }
 
-function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension>>): string {
+function generateLazyExtensionRoutes(extensions: Array<AdminUiExtensionWithId>): string {
     const routes: string[] = [];
-    for (const extension of extensions as Array<Required<AdminUiExtension>>) {
+    for (const extension of extensions as Array<AdminUiExtensionWithId>) {
         for (const module of extension.ngModules) {
             if (module.type === 'lazy') {
                 routes.push(`  {
@@ -159,7 +180,7 @@ function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension
     return `export const extensionRoutes = [${routes.join(',\n')}];\n`;
 }
 
-function generateSharedExtensionModule(extensions: Array<Required<AdminUiExtension>>) {
+function generateSharedExtensionModule(extensions: Array<AdminUiExtensionWithId>) {
     return `import { NgModule } from '@angular/core';
 import { CommonModule } from '@angular/common';
 ${extensions
@@ -193,15 +214,17 @@ function getModuleFilePath(
 }
 
 /**
- * Copy the Admin UI sources & static assets to the outputPath if it does not already
- * exists there.
+ * Copies the Admin UI sources & static assets to the outputPath if it does not already
+ * exist there.
  */
-function copyAdminUiSource(outputPath: string) {
-    const angularJsonFile = path.join(outputPath, 'angular.json');
-    const indexFile = path.join(outputPath, '/src/index.html');
-    if (fs.existsSync(angularJsonFile) && fs.existsSync(indexFile)) {
+function copyAdminUiSource(outputPath: string, modulePathMapping: Record<string, string[]> | undefined) {
+    const tsconfigFilePath = path.join(outputPath, 'tsconfig.json');
+    const indexFilePath = path.join(outputPath, '/src/index.html');
+    if (fs.existsSync(tsconfigFilePath) && fs.existsSync(indexFilePath)) {
+        configureModulePathMapping(tsconfigFilePath, modulePathMapping);
         return;
     }
+
     const scaffoldDir = path.join(__dirname, '../scaffold');
     const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static');
 
@@ -216,6 +239,7 @@ function copyAdminUiSource(outputPath: string) {
     fs.removeSync(outputPath);
     fs.ensureDirSync(outputPath);
     fs.copySync(scaffoldDir, outputPath);
+    configureModulePathMapping(tsconfigFilePath, modulePathMapping);
 
     // copy source files from admin-ui package
     const outputSrc = path.join(outputPath, 'src');
@@ -223,6 +247,22 @@ function copyAdminUiSource(outputPath: string) {
     fs.copySync(adminUiSrc, outputSrc);
 }
 
+/**
+ * Adds module path mapping to the bundled tsconfig.json file if defined as a UI extension.
+ */
+function configureModulePathMapping(
+    tsconfigFilePath: string,
+    modulePathMapping: Record<string, string[]> | undefined,
+) {
+    if (!modulePathMapping) {
+        return;
+    }
+
+    const tsconfig = require(tsconfigFilePath);
+    tsconfig.compilerOptions.paths = modulePathMapping;
+    fs.writeFileSync(tsconfigFilePath, JSON.stringify(tsconfig, null, 2));
+}
+
 /**
  * Attempts to find out it the ngcc compiler has been run on the Angular packages, and if not,
  * attemps to run it. This is done this way because attempting to run ngcc from a sub-directory

+ 92 - 0
packages/ui-devkit/src/compiler/types.ts

@@ -113,11 +113,99 @@ export interface AdminUiExtension
      * scss style sheets etc.
      */
     extensionPath: string;
+
     /**
      * @description
      * One or more Angular modules which extend the default Admin UI.
      */
     ngModules: Array<AdminUiExtensionSharedModule | AdminUiExtensionLazyModule>;
+
+    /**
+     * @description
+     * An optional alias for the module so it can be referenced by other UI extension modules.
+     *
+     * By default, Angular modules declared in an AdminUiExtension do not have access to code outside the directory
+     * defined by the `extensionPath`. A scenario in which that can be useful though is in a monorepo codebase where
+     * a common NgModule is shared across different plugins, each defined in its own package. An example can be found
+     * below - note that the main `tsconfig.json` also maps the target module but using a path relative to the project's
+     * root folder. The UI module is not part of the main TypeScript build task as explained in
+     * [Extending the Admin UI](https://www.vendure.io/docs/plugins/extending-the-admin-ui/) but having `paths`
+     * properly configured helps with usual IDE code editing features such as code completion and quick navigation, as
+     * well as linting.
+     *
+     * @example
+     * ```ts
+     * // packages/common-ui-module/src/ui/ui-shared.module.ts
+     * import { NgModule } from '\@angular/core';
+     * import { SharedModule } from '\@vendure/admin-ui/core';
+     * import { CommonUiComponent } from './components/common-ui/common-ui.component';
+     *
+     * export { CommonUiComponent };
+     *
+     * \@NgModule({
+     *  imports: [SharedModule],
+     *  exports: [CommonUiComponent],
+     *  declarations: [CommonUiComponent],
+     * })
+     * export class CommonSharedUiModule {}
+     * ```
+     *
+     * ```ts
+     * // packages/common-ui-module/src/index.ts
+     * import path from 'path';
+     *
+     * import { AdminUiExtension } from '\@vendure/ui-devkit/compiler';
+     *
+     * export const uiExtensions: AdminUiExtension = {
+     *   pathAlias: '\@common-ui-module',     // this is the important part
+     *   extensionPath: path.join(__dirname, 'ui'),
+     *   ngModules: [
+     *     {
+     *       type: 'shared' as const,
+     *       ngModuleFileName: 'ui-shared.module.ts',
+     *       ngModuleName: 'CommonSharedUiModule',
+     *     },
+     *   ],
+     * };
+     * ```
+     *
+     * ```json
+     * // tsconfig.json
+     * {
+     *   "compilerOptions": {
+     *     "baseUrl": ".",
+     *     "paths": {
+     *       "\@common-ui-module/*": ["packages/common-ui-module/src/ui/*"]
+     *     }
+     *   }
+     * }
+     * ```
+     *
+     * ```ts
+     * // packages/sample-plugin/src/ui/ui-extension.module.ts
+     * import { NgModule } from '\@angular/core';
+     * import { SharedModule } from '\@vendure/admin-ui/core';
+     * // the import below works both in the context of the custom Admin UI app as well as the main project
+     * // '\@common-ui-module' is the value of "pathAlias" and 'ui-shared.module' is the file we want to reference inside "extensionPath"
+     * import { CommonSharedUiModule, CommonUiComponent } from '\@common-ui-module/ui-shared.module';
+     *
+     * \@NgModule({
+     *   imports: [
+     *     SharedModule,
+     *     CommonSharedUiModule,
+     *     RouterModule.forChild([
+     *       {
+     *         path: '',
+     *         pathMatch: 'full',
+     *         component: CommonUiComponent,
+     *       },
+     *     ]),
+     *   ],
+     * })
+     * export class SampleUiExtensionModule {}
+     * ```
+     */
+    pathAlias?: string;
 }
 
 /**
@@ -280,3 +368,7 @@ export interface BrandingOptions {
     largeLogoPath?: string;
     faviconPath?: string;
 }
+
+export interface AdminUiExtensionWithId extends AdminUiExtension {
+    id: string;
+}

+ 2 - 1
packages/ui-devkit/src/compiler/utils.ts

@@ -8,6 +8,7 @@ import * as path from 'path';
 import { STATIC_ASSETS_OUTPUT_DIR } from './constants';
 import {
     AdminUiExtension,
+    AdminUiExtensionWithId,
     Extension,
     GlobalStylesExtension,
     SassVariableOverridesExtension,
@@ -79,7 +80,7 @@ export async function copyStaticAsset(outputPath: string, staticAssetDef: Static
  * If not defined by the user, a deterministic ID is generated
  * from a hash of the extension config.
  */
-export function normalizeExtensions(extensions?: AdminUiExtension[]): Array<Required<AdminUiExtension>> {
+export function normalizeExtensions(extensions?: AdminUiExtension[]): Array<AdminUiExtensionWithId> {
     return (extensions || []).map(e => {
         let id = e.id;
         if (!id) {