Browse Source

feat(dashboard): Add support for monorepo setups

Michael Bromley 6 months ago
parent
commit
76759d96d4

+ 110 - 8
packages/dashboard/vite/utils/config-loader.ts

@@ -31,9 +31,83 @@ const defaultLogger: Logger = {
     },
 };
 
+/**
+ * @description
+ * The PathAdapter interface allows customization of how paths are handled
+ * when compiling the Vendure config and its imports.
+ *
+ * This is particularly useful in complex project structures, such as monorepos,
+ * where the Vendure config file may not be in the root directory,
+ * or when you need to transform TypeScript path mappings.
+ */
+export interface PathAdapter {
+    /**
+     * @description
+     * A function to determine the path to the compiled Vendure config file. The default implementation
+     * simple joins the output directory with the config file name:
+     *
+     * ```ts
+     * return path.join(outputPath, configFileName)
+     * ```
+     *
+     * However, in some cases with more complex project structures, you may need to
+     * provide a custom implementation to ensure the compiled config file is
+     * correctly located.
+     *
+     * @example
+     * ```ts
+     * getCompiledConfigPath: ({ inputRootDir, outputPath, configFileName }) => {
+     *     const projectName = inputRootDir.split('/libs/')[1].split('/')[0];
+     *     const pathAfterProject = inputRootDir.split(`/libs/${projectName}`)[1];
+     *     const compiledConfigFilePath = `${outputPath}/${projectName}${pathAfterProject}`;
+     *     return path.join(compiledConfigFilePath, configFileName);
+     * },
+     * ```
+     */
+    getCompiledConfigPath?: (params: {
+        inputRootDir: string;
+        outputPath: string;
+        configFileName: string;
+    }) => string;
+    /**
+     * If your project makes use of the TypeScript `paths` configuration, the compiler will
+     * attempt to use these paths when compiling the Vendure config and its imports.
+     *
+     * In certain cases, you may need to transform these paths before they are used. For instance,
+     * if your project is a monorepo and the paths are defined relative to the root of the monorepo,
+     * you may need to adjust them to be relative to the output directory where the compiled files are located.
+     *
+     * @example
+     * ```ts
+     * transformTsConfigPathMappings: ({ phase, patterns }) => {
+     *     // "loading" phase is when the compiled Vendure code is being loaded by
+     *     // the plugin, in order to introspect the configuration of your app.
+     *     if (phase === 'loading') {
+     *         return patterns.map((p) =>
+     *             p.replace('libs/', '').replace(/.ts$/, '.js'),
+     *         );
+     *     }
+     *     return patterns;
+     * },
+     * ```
+     * @param params
+     */
+    transformTsConfigPathMappings?: (params: {
+        phase: 'compiling' | 'loading';
+        alias: string;
+        patterns: string[];
+    }) => string[];
+}
+
+const defaultPathAdapter: Required<PathAdapter> = {
+    getCompiledConfigPath: ({ outputPath, configFileName }) => path.join(outputPath, configFileName),
+    transformTsConfigPathMappings: ({ patterns }) => patterns,
+};
+
 export interface ConfigLoaderOptions {
     vendureConfigPath: string;
     tempDir: string;
+    pathAdapter?: PathAdapter;
     vendureConfigExport?: string;
     logger?: Logger;
     reportCompilationErrors?: boolean;
@@ -62,7 +136,11 @@ export interface LoadVendureConfigResult {
  * to handle the compiled JavaScript output.
  */
 export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<LoadVendureConfigResult> {
-    const { vendureConfigPath, vendureConfigExport, tempDir } = options;
+    const { vendureConfigPath, vendureConfigExport, tempDir, pathAdapter } = options;
+    const getCompiledConfigPath =
+        pathAdapter?.getCompiledConfigPath ?? defaultPathAdapter.getCompiledConfigPath;
+    const transformTsConfigPathMappings =
+        pathAdapter?.transformTsConfigPathMappings ?? defaultPathAdapter.transformTsConfigPathMappings;
     const logger = options.logger || defaultLogger;
     const outputPath = tempDir;
     const configFileName = path.basename(vendureConfigPath);
@@ -73,11 +151,15 @@ export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<L
         inputPath: vendureConfigPath,
         outputDir: outputPath,
         logger,
+        transformTsConfigPathMappings,
     });
-    const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
-        /.ts$/,
-        '.js',
-    );
+    const compiledConfigFilePath = pathToFileURL(
+        getCompiledConfigPath({
+            inputRootDir,
+            outputPath,
+            configFileName,
+        }),
+    ).href.replace(/.ts$/, '.js');
     // create package.json with type commonjs and save it to the output dir
     await fs.writeFile(path.join(outputPath, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2));
 
@@ -98,7 +180,12 @@ export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<L
     }
 
     // Register path aliases from tsconfig before importing
-    const tsConfigInfo = await findTsConfigPaths(vendureConfigPath, logger);
+    const tsConfigInfo = await findTsConfigPaths(
+        vendureConfigPath,
+        logger,
+        'loading',
+        transformTsConfigPathMappings,
+    );
     if (tsConfigInfo) {
         tsConfigPaths.register({
             baseUrl: outputPath,
@@ -122,6 +209,8 @@ export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<L
 async function findTsConfigPaths(
     configPath: string,
     logger: Logger,
+    phase: 'compiling' | 'loading',
+    transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'],
 ): Promise<{ baseUrl: string; paths: Record<string, string[]> } | undefined> {
     const configDir = path.dirname(configPath);
     let currentDir = configDir;
@@ -147,10 +236,15 @@ async function findTsConfigPaths(
 
                         for (const [alias, patterns] of Object.entries(compilerOptions.paths)) {
                             // Store paths as defined in tsconfig, they will be relative to baseUrl
-                            paths[alias] = (patterns as string[]).map(pattern =>
+                            const normalizedPatterns = (patterns as string[]).map(pattern =>
                                 // Normalize slashes for consistency, keep relative
                                 pattern.replace(/\\/g, '/'),
                             );
+                            paths[alias] = transformTsConfigPathMappings({
+                                phase,
+                                alias,
+                                patterns: normalizedPatterns,
+                            });
                         }
                         logger.debug(
                             `Found tsconfig paths in ${tsConfigPath}: ${JSON.stringify(
@@ -184,6 +278,7 @@ type CompileFileOptions = {
     inputRootDir: string;
     inputPath: string;
     outputDir: string;
+    transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'];
     logger?: Logger;
     compiledFiles?: Set<string>;
     isRoot?: boolean;
@@ -195,6 +290,7 @@ export async function compileFile({
     inputRootDir,
     inputPath,
     outputDir,
+    transformTsConfigPathMappings,
     logger = defaultLogger,
     compiledFiles = new Set<string>(),
     isRoot = true,
@@ -220,7 +316,12 @@ export async function compileFile({
     let tsConfigInfo: { baseUrl: string; paths: Record<string, string[]> } | undefined;
 
     if (isRoot) {
-        tsConfigInfo = await findTsConfigPaths(absoluteInputPath, logger);
+        tsConfigInfo = await findTsConfigPaths(
+            absoluteInputPath,
+            logger,
+            'compiling',
+            transformTsConfigPathMappings,
+        );
         if (tsConfigInfo) {
             logger?.debug(`Using TypeScript configuration: ${JSON.stringify(tsConfigInfo, null, 2)}`);
         }
@@ -355,6 +456,7 @@ export async function compileFile({
             inputPath: importPath,
             outputDir,
             logger,
+            transformTsConfigPathMappings,
             compiledFiles,
             isRoot: false,
             pluginInfo,

+ 6 - 1
packages/dashboard/vite/vite-plugin-config-loader.ts

@@ -24,6 +24,7 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
             try {
                 const startTime = Date.now();
                 result = await loadVendureConfig({
+                    pathAdapter: options.pathAdapter,
                     tempDir: options.tempDir,
                     vendureConfigPath: options.vendureConfigPath,
                     vendureConfigExport: options.vendureConfigExport,
@@ -42,7 +43,11 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
                 );
             } catch (e: unknown) {
                 if (e instanceof Error) {
-                    this.error(`Error loading Vendure config: ${e.message}`);
+                    const message = [
+                        e.message,
+                        `If you are using a monorepo, you may need to provide a custom pathAdapter to resolve the paths correctly.`,
+                    ].join('\n');
+                    this.error(`Error loading Vendure config: ${message}`);
                 }
             }
             onConfigLoaded.forEach(fn => fn());

+ 42 - 0
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
 import path from 'path';
 import { PluginOption } from 'vite';
 
+import { PathAdapter } from './utils/config-loader.js';
 import { adminApiSchemaPlugin } from './vite-plugin-admin-api-schema.js';
 import { configLoaderPlugin } from './vite-plugin-config-loader.js';
 import { viteConfigPlugin } from './vite-plugin-config.js';
@@ -23,6 +24,46 @@ export type VitePluginVendureDashboardOptions = {
      * The path to the Vendure server configuration file.
      */
     vendureConfigPath: string | URL;
+    /**
+     * @description
+     * The {@link PathAdapter} allows you to customize the resolution of paths
+     * in the compiled Vendure source code which is used as part of the
+     * introspection step of building the dashboard.
+     *
+     * It enables support for more complex repository structures, such as
+     * monorepos, where the Vendure server configuration file may not
+     * be located in the root directory of the project.
+     *
+     * If you get compilation errors like "Error loading Vendure config: Cannot find module",
+     * you probably need to provide a custom `pathAdapter` to resolve the paths correctly.
+     *
+     * @example
+     * ```ts
+     * vendureDashboardPlugin({
+     *     tempCompilationDir: join(__dirname, './__vendure-dashboard-temp'),
+     *     pathAdapter: {
+     *         getCompiledConfigPath: ({ inputRootDir, outputPath, configFileName }) => {
+     *             const projectName = inputRootDir.split('/libs/')[1].split('/')[0];
+     *             const pathAfterProject = inputRootDir.split(`/libs/${projectName}`)[1];
+     *             const compiledConfigFilePath = `${outputPath}/${projectName}${pathAfterProject}`;
+     *             return path.join(compiledConfigFilePath, configFileName);
+     *         },
+     *         transformTsConfigPathMappings: ({ phase, patterns }) => {
+     *             // "loading" phase is when the compiled Vendure code is being loaded by
+     *             // the plugin, in order to introspect the configuration of your app.
+     *             if (phase === 'loading') {
+     *                 return patterns.map((p) =>
+     *                     p.replace('libs/', '').replace(/.ts$/, '.js'),
+     *                 );
+     *             }
+     *             return patterns;
+     *         },
+     *     },
+     *     // ...
+     * }),
+     * ```
+     */
+    pathAdapter?: PathAdapter;
     /**
      * @description
      * The name of the exported variable from the Vendure server configuration file.
@@ -85,6 +126,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
             vendureConfigPath: normalizedVendureConfigPath,
             tempDir,
             reportCompilationErrors: options.reportCompilationErrors,
+            pathAdapter: options.pathAdapter,
         }),
         viteConfigPlugin({ packageRoot }),
         adminApiSchemaPlugin(),