Sfoglia il codice sorgente

feat(dashboard): Support tsconfig path mappings when loading config

Michael Bromley 9 mesi fa
parent
commit
ce79025f21

+ 51 - 1
package-lock.json

@@ -47234,6 +47234,7 @@
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.6",
         "tailwindcss-animate": "^1.0.7",
+        "tsconfig-paths": "^4.2.0",
         "tw-animate-css": "^1.2.4",
         "vite": "^6.1.0",
         "zod": "^3.24.2"
@@ -47521,6 +47522,29 @@
         "node": ">=8"
       }
     },
+    "packages/dashboard/node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "packages/dashboard/node_modules/tsconfig-paths": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+      "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+      "license": "MIT",
+      "dependencies": {
+        "json5": "^2.2.2",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "packages/dashboard/node_modules/undici-types": {
       "version": "6.20.0",
       "dev": true,
@@ -47547,7 +47571,8 @@
         "csv-stringify": "^6.4.6",
         "dayjs": "^1.11.10",
         "jsdom": "^26.0.0",
-        "progress": "^2.0.3"
+        "progress": "^2.0.3",
+        "tsconfig-paths": "^4.2.0"
       }
     },
     "packages/dev-server/node_modules/commander": {
@@ -47558,6 +47583,31 @@
         "node": ">=18"
       }
     },
+    "packages/dev-server/node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "packages/dev-server/node_modules/tsconfig-paths": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+      "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json5": "^2.2.2",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "packages/elasticsearch-plugin": {
       "name": "@vendure/elasticsearch-plugin",
       "version": "3.2.3",

+ 2 - 1
packages/dashboard/package.json

@@ -112,7 +112,8 @@
         "tailwindcss-animate": "^1.0.7",
         "tw-animate-css": "^1.2.4",
         "vite": "^6.1.0",
-        "zod": "^3.24.2"
+        "zod": "^3.24.2",
+        "tsconfig-paths": "^4.2.0"
     },
     "devDependencies": {
         "@eslint/js": "^9.19.0",

+ 275 - 36
packages/dashboard/vite/config-loader.ts

@@ -1,13 +1,33 @@
 import { VendureConfig } from '@vendure/core';
 import fs from 'fs-extra';
 import path from 'path';
+import tsConfigPaths from 'tsconfig-paths';
 import * as ts from 'typescript';
 import { pathToFileURL } from 'url';
 
+type Logger = {
+    info: (message: string) => void;
+    warn: (message: string) => void;
+    debug: (message: string) => void;
+};
+
+const defaultLogger: Logger = {
+    info: (message: string) => {
+        /* noop */
+    },
+    warn: (message: string) => {
+        /* noop */
+    },
+    debug: (message: string) => {
+        /* noop */
+    },
+};
+
 export interface ConfigLoaderOptions {
     vendureConfigPath: string;
     tempDir: string;
     vendureConfigExport?: string;
+    logger?: Logger;
 }
 
 /**
@@ -30,11 +50,12 @@ export async function loadVendureConfig(
     options: ConfigLoaderOptions,
 ): Promise<{ vendureConfig: VendureConfig; exportedSymbolName: string }> {
     const { vendureConfigPath, vendureConfigExport, tempDir } = options;
+    const logger = options.logger || defaultLogger;
     const outputPath = tempDir;
     const configFileName = path.basename(vendureConfigPath);
     const inputRootDir = path.dirname(vendureConfigPath);
     await fs.remove(outputPath);
-    await compileFile(inputRootDir, vendureConfigPath, outputPath);
+    await compileFile(inputRootDir, vendureConfigPath, outputPath, logger);
     const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
         /.ts$/,
         '.js',
@@ -57,6 +78,16 @@ export async function loadVendureConfig(
             `Could not find a variable exported as VendureConfig. Please specify the name of the exported variable using the "vendureConfigExport" option.`,
         );
     }
+
+    // Register path aliases from tsconfig before importing
+    const tsConfigInfo = await findTsConfigPaths(vendureConfigPath, logger);
+    if (tsConfigInfo) {
+        tsConfigPaths.register({
+            baseUrl: outputPath,
+            paths: tsConfigInfo.paths,
+        });
+    }
+
     const config = await import(compiledConfigFilePath).then(m => m[configExportedSymbolName]);
     if (!config) {
         throw new Error(
@@ -98,17 +129,77 @@ function findConfigExport(sourceFile: ts.SourceFile): string | undefined {
     return exportedSymbolName;
 }
 
+/**
+ * Finds and parses tsconfig files in the given directory and its parent directories.
+ * Returns the paths configuration if found.
+ */
+async function findTsConfigPaths(
+    configPath: string,
+    logger: Logger,
+): Promise<{ baseUrl: string; paths: Record<string, string[]> } | undefined> {
+    const configDir = path.dirname(configPath);
+    let currentDir = configDir;
+
+    while (currentDir !== path.parse(currentDir).root) {
+        try {
+            const files = await fs.readdir(currentDir);
+            const tsConfigFiles = files.filter(file => /^tsconfig(\..*)?\.json$/.test(file));
+
+            for (const fileName of tsConfigFiles) {
+                const tsConfigPath = path.join(currentDir, fileName);
+                try {
+                    const tsConfigContent = await fs.readFile(tsConfigPath, 'utf-8');
+                    // Use JSON5 or similar parser if comments are expected in tsconfig.json
+                    // For simplicity, assuming standard JSON here. Handle parse errors.
+                    const tsConfig = JSON.parse(tsConfigContent);
+                    const compilerOptions = tsConfig.compilerOptions || {};
+
+                    if (compilerOptions.paths) {
+                        // Determine the effective baseUrl: explicitly set or the directory of tsconfig.json
+                        const tsConfigBaseUrl = path.resolve(currentDir, compilerOptions.baseUrl || '.');
+                        const paths: Record<string, string[]> = {};
+
+                        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 =>
+                                // Normalize slashes for consistency, keep relative
+                                pattern.replace(/\\/g, '/'),
+                            );
+                        }
+                        logger.debug(
+                            `Found tsconfig paths in ${tsConfigPath}: ${JSON.stringify({ baseUrl: tsConfigBaseUrl, paths }, null, 2)}`,
+                        );
+                        return { baseUrl: tsConfigBaseUrl, paths };
+                    }
+                } catch (e: any) {
+                    logger.warn(
+                        `Could not read or parse tsconfig file ${tsConfigPath}: ${e.message as string}`,
+                    );
+                }
+            }
+        } catch (e: any) {
+            // If we can't read the directory, just continue to the parent
+            logger.warn(`Could not read directory ${currentDir}: ${e.message as string}`);
+        }
+        currentDir = path.dirname(currentDir);
+    }
+    logger.debug(`No tsconfig paths found traversing up from ${configDir}`);
+    return undefined;
+}
+
 export async function compileFile(
     inputRootDir: string,
     inputPath: string,
     outputDir: string,
+    logger: Logger = defaultLogger,
     compiledFiles = new Set<string>(),
     isRoot = true,
 ): Promise<void> {
-    if (compiledFiles.has(inputPath)) {
+    const absoluteInputPath = path.resolve(inputPath);
+    if (compiledFiles.has(absoluteInputPath)) {
         return;
     }
-    compiledFiles.add(inputPath);
+    compiledFiles.add(absoluteInputPath);
 
     // Ensure output directory exists
     await fs.ensureDir(outputDir);
@@ -117,62 +208,210 @@ export async function compileFile(
     const source = await fs.readFile(inputPath, 'utf-8');
 
     // Parse the source to find relative imports
-    const sourceFile = ts.createSourceFile(inputPath, source, ts.ScriptTarget.Latest, true);
+    const sourceFile = ts.createSourceFile(absoluteInputPath, source, ts.ScriptTarget.Latest, true);
 
     const importPaths = new Set<string>();
+    let tsConfigInfo: { baseUrl: string; paths: Record<string, string[]> } | undefined;
+
+    if (isRoot) {
+        tsConfigInfo = await findTsConfigPaths(absoluteInputPath, logger);
+        if (tsConfigInfo) {
+            logger?.debug(`Using TypeScript configuration: ${JSON.stringify(tsConfigInfo, null, 2)}`);
+        }
+    }
 
-    function collectImports(node: ts.Node) {
+    async function collectImports(node: ts.Node) {
         if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
             const importPath = node.moduleSpecifier.text;
+
+            // Handle relative imports
             if (importPath.startsWith('.')) {
-                const resolvedPath = path.resolve(path.dirname(inputPath), importPath);
-                importPaths.add(resolvedPath + '.ts');
+                const resolvedPath = path.resolve(path.dirname(absoluteInputPath), importPath);
+                let resolvedTsPath = resolvedPath + '.ts';
+                // Also check for .tsx if .ts doesn't exist
+                if (!(await fs.pathExists(resolvedTsPath))) {
+                    const resolvedTsxPath = resolvedPath + '.tsx';
+                    if (await fs.pathExists(resolvedTsxPath)) {
+                        resolvedTsPath = resolvedTsxPath;
+                    } else {
+                        // If neither exists, maybe it's an index file?
+                        const resolvedIndexPath = path.join(resolvedPath, 'index.ts');
+                        if (await fs.pathExists(resolvedIndexPath)) {
+                            resolvedTsPath = resolvedIndexPath;
+                        } else {
+                            const resolvedIndexTsxPath = path.join(resolvedPath, 'index.tsx');
+                            if (await fs.pathExists(resolvedIndexTsxPath)) {
+                                resolvedTsPath = resolvedIndexTsxPath;
+                            } else {
+                                // If still not found, log a warning or let TS handle it later
+                                logger?.warn(
+                                    `Could not resolve relative import "${importPath}" from "${absoluteInputPath}" to an existing .ts/.tsx file.`,
+                                );
+                                // Do not add to importPaths if we can't verify existence
+                                return;
+                            }
+                        }
+                    }
+                }
+                importPaths.add(resolvedTsPath);
+            }
+            // Handle path aliases if tsConfigInfo exists
+            else if (tsConfigInfo) {
+                // Attempt to resolve using path aliases
+                let resolved = false;
+                for (const [alias, patterns] of Object.entries(tsConfigInfo.paths)) {
+                    const aliasPrefix = alias.replace('*', '');
+                    const aliasSuffix = alias.endsWith('*') ? '*' : '';
+
+                    if (
+                        importPath.startsWith(aliasPrefix) &&
+                        (aliasSuffix === '*' || importPath === aliasPrefix)
+                    ) {
+                        const remainingImportPath = importPath.slice(aliasPrefix.length);
+                        for (const pattern of patterns) {
+                            const patternPrefix = pattern.replace('*', '');
+                            const patternSuffix = pattern.endsWith('*') ? '*' : '';
+                            // Ensure suffix match consistency (* vs exact)
+                            if (aliasSuffix !== patternSuffix) continue;
+
+                            const potentialPathBase = path.resolve(tsConfigInfo.baseUrl, patternPrefix);
+                            const resolvedPath = path.join(potentialPathBase, remainingImportPath);
+
+                            let resolvedTsPath = resolvedPath + '.ts';
+                            // Similar existence checks as relative paths
+                            if (!(await fs.pathExists(resolvedTsPath))) {
+                                const resolvedTsxPath = resolvedPath + '.tsx';
+                                if (await fs.pathExists(resolvedTsxPath)) {
+                                    resolvedTsPath = resolvedTsxPath;
+                                } else {
+                                    const resolvedIndexPath = path.join(resolvedPath, 'index.ts');
+                                    if (await fs.pathExists(resolvedIndexPath)) {
+                                        resolvedTsPath = resolvedIndexPath;
+                                    } else {
+                                        const resolvedIndexTsxPath = path.join(resolvedPath, 'index.tsx');
+                                        if (await fs.pathExists(resolvedIndexTsxPath)) {
+                                            resolvedTsPath = resolvedIndexTsxPath;
+                                        } else {
+                                            // Path doesn't resolve to a file for this pattern
+                                            continue;
+                                        }
+                                    }
+                                }
+                            }
+                            // Add the first successful resolution for this alias
+                            importPaths.add(resolvedTsPath);
+                            resolved = true;
+                            break; // Stop checking patterns for this alias
+                        }
+                    }
+                    if (resolved) break; // Stop checking other aliases if resolved
+                }
+            }
+            // For all other imports (node_modules, etc), we should still add them to be processed
+            // by the TypeScript compiler, even if we can't resolve them to a file
+            else {
+                // Add the import path as is - TypeScript will handle resolution
+                // importPaths.add(importPath);
+            }
+        } else {
+            const children = node.getChildren();
+            for (const child of children) {
+                // Only process nodes that could contain import statements
+                if (
+                    ts.isSourceFile(child) ||
+                    ts.isModuleBlock(child) ||
+                    ts.isModuleDeclaration(child) ||
+                    ts.isImportDeclaration(child) ||
+                    child.kind === ts.SyntaxKind.SyntaxList
+                ) {
+                    await collectImports(child);
+                }
             }
         }
-        ts.forEachChild(node, collectImports);
     }
 
-    collectImports(sourceFile);
+    // Start collecting imports from the source file
+    await collectImports(sourceFile);
+
+    // Store the tsConfigInfo on the first call if found
+    const rootTsConfigInfo = isRoot ? tsConfigInfo : undefined;
 
     // Recursively collect all files that need to be compiled
     for (const importPath of importPaths) {
-        await compileFile(inputRootDir, importPath, outputDir, compiledFiles, false);
+        // Pass rootTsConfigInfo down, but set isRoot to false
+        await compileFile(inputRootDir, importPath, outputDir, logger, compiledFiles, false);
     }
 
     // If this is the root file (the one that started the compilation),
-    // transpile all files
+    // use the TypeScript compiler API to compile all files together
     if (isRoot) {
+        logger.info(`Starting compilation for ${compiledFiles.size} files...`);
         const allFiles = Array.from(compiledFiles);
-        for (const file of allFiles) {
-            const fileSource = await fs.readFile(file, 'utf-8');
-            const result = ts.transpileModule(fileSource, {
-                compilerOptions: {
-                    target: ts.ScriptTarget.ES2020,
-                    module: ts.ModuleKind.CommonJS,
-                    experimentalDecorators: true,
-                    emitDecoratorMetadata: true,
-                    sourceMap: true,
-                    esModuleInterop: true,
-                    skipLibCheck: true,
-                    forceConsistentCasingInFileNames: true,
-                },
-                fileName: file,
-            });
+        const compilerOptions: ts.CompilerOptions = {
+            // Base options
+            target: ts.ScriptTarget.ES2020,
+            module: ts.ModuleKind.CommonJS, // Output CommonJS for Node compatibility
+            experimentalDecorators: true,
+            emitDecoratorMetadata: true,
+            esModuleInterop: true,
+            skipLibCheck: true, // Faster compilation
+            forceConsistentCasingInFileNames: true,
+            moduleResolution: ts.ModuleResolutionKind.NodeJs, // Use Node.js module resolution
+
+            // Output options
+            outDir: outputDir, // Output directory for all compiled files
+            sourceMap: true, // Generate source maps
+            declaration: false, // Don't generate .d.ts files
 
-            // Generate output file path
-            const relativePath = path.relative(inputRootDir, file);
-            const outputPath = path.join(outputDir, relativePath).replace(/\.ts$/, '.js');
+            // Path resolution options - use info found from tsconfig
+            baseUrl: rootTsConfigInfo ? rootTsConfigInfo.baseUrl : undefined, // Let TS handle resolution if no baseUrl
+            paths: rootTsConfigInfo ? rootTsConfigInfo.paths : undefined,
+            // rootDir: inputRootDir, // Often inferred correctly, can cause issues if set explicitly sometimes
+            allowJs: true, // Allow JS files if needed, though we primarily collect TS
+            resolveJsonModule: true, // Allow importing JSON
+        };
 
-            // Ensure the subdirectory for the output file exists
-            await fs.ensureDir(path.dirname(outputPath));
+        logger.debug(`compilerOptions: ${JSON.stringify(compilerOptions, null, 2)}`);
 
-            // Write the transpiled code
-            await fs.writeFile(outputPath, result.outputText);
+        // Create a Program to represent the compilation context
+        const program = ts.createProgram(allFiles, compilerOptions);
 
-            // Write source map if available
-            if (result.sourceMapText) {
-                await fs.writeFile(`${outputPath}.map`, result.sourceMapText);
+        // Perform the compilation and emit files
+        logger.info(`Emitting compiled files to ${outputDir}`);
+        const emitResult = program.emit();
+
+        // Report diagnostics (errors and warnings)
+        const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
+
+        let hasEmitErrors = emitResult.emitSkipped;
+        allDiagnostics.forEach(diagnostic => {
+            if (diagnostic.file && diagnostic.start) {
+                const { line, character } = ts.getLineAndCharacterOfPosition(
+                    diagnostic.file,
+                    diagnostic.start,
+                );
+                const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+                const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
+                logFn(
+                    `TS${diagnostic.code} ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`,
+                );
+                if (diagnostic.category === ts.DiagnosticCategory.Error) {
+                    hasEmitErrors = true;
+                }
+            } else {
+                const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+                const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
+                logFn(`TS${diagnostic.code}: ${message}`);
+                if (diagnostic.category === ts.DiagnosticCategory.Error) {
+                    hasEmitErrors = true;
+                }
             }
+        });
+
+        if (hasEmitErrors) {
+            throw new Error('TypeScript compilation failed with errors.');
         }
+
+        logger.info(`Successfully compiled ${allFiles.length} files to ${outputDir}`);
     }
 }

+ 5 - 0
packages/dashboard/vite/vite-plugin-config-loader.ts

@@ -28,6 +28,11 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
                     tempDir: options.tempDir,
                     vendureConfigPath: options.vendureConfigPath,
                     vendureConfigExport: options.vendureConfigExport,
+                    logger: {
+                        info: (message: string) => this.info(message),
+                        warn: (message: string) => this.warn(message),
+                        debug: (message: string) => this.debug(message),
+                    },
                 });
                 vendureConfig = result.vendureConfig;
                 const endTime = Date.now();