Bladeren bron

fix(dashboard): Transform Lingui macros in third-party npm packages (#4182)

Michael Bromley 1 dag geleden
bovenliggende
commit
c9407780ab

+ 16 - 0
packages/dashboard/vite/tests/fixtures-lingui-npm-plugin/fake_node_modules/@test-scope/lingui-plugin/dashboard/index.tsx

@@ -0,0 +1,16 @@
+import { Trans } from '@lingui/react/macro';
+import { t } from '@lingui/core/macro';
+
+export function TestComponent() {
+    const greeting = t`Hello from the plugin`;
+    return (
+        <div>
+            <Trans>Welcome to the Lingui test plugin dashboard</Trans>
+            <p>{greeting}</p>
+        </div>
+    );
+}
+
+export default {
+    routes: [],
+};

+ 19 - 0
packages/dashboard/vite/tests/fixtures-lingui-npm-plugin/fake_node_modules/@test-scope/lingui-plugin/index.js

@@ -0,0 +1,19 @@
+"use strict";
+var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
+    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
+    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
+    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
+    return c > 3 && r && Object.defineProperty(target, key, r), r;
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.LinguiTestPlugin = void 0;
+const core_1 = require("@vendure/core");
+let LinguiTestPlugin = class LinguiTestPlugin {
+};
+exports.LinguiTestPlugin = LinguiTestPlugin;
+exports.LinguiTestPlugin = LinguiTestPlugin = __decorate([
+    (0, core_1.VendurePlugin)({
+        imports: [core_1.PluginCommonModule],
+        dashboard: './dashboard/index.tsx',
+    })
+], LinguiTestPlugin);

+ 5 - 0
packages/dashboard/vite/tests/fixtures-lingui-npm-plugin/fake_node_modules/@test-scope/lingui-plugin/package.json

@@ -0,0 +1,5 @@
+{
+  "name": "@test-scope/lingui-plugin",
+  "version": "1.0.0",
+  "main": "index.js"
+}

+ 5 - 0
packages/dashboard/vite/tests/fixtures-lingui-npm-plugin/package.json

@@ -0,0 +1,5 @@
+{
+  "name": "fixtures-lingui-npm-plugin",
+  "version": "1.0.0",
+  "private": true
+}

+ 18 - 0
packages/dashboard/vite/tests/fixtures-lingui-npm-plugin/vendure-config.ts

@@ -0,0 +1,18 @@
+import { LinguiTestPlugin } from '@test-scope/lingui-plugin';
+import { VendureConfig } from '@vendure/core';
+
+export const config: VendureConfig = {
+    apiOptions: {
+        port: 3000,
+    },
+    authOptions: {
+        tokenMethod: 'bearer',
+    },
+    dbConnectionOptions: {
+        type: 'postgres',
+    },
+    paymentOptions: {
+        paymentMethodHandlers: [],
+    },
+    plugins: [LinguiTestPlugin],
+};

+ 156 - 0
packages/dashboard/vite/tests/lingui-npm-plugin.spec.ts

@@ -0,0 +1,156 @@
+import { readFile, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import tsconfigPaths from 'tsconfig-paths';
+import { describe, expect, it } from 'vitest';
+
+import { compile } from '../utils/compiler.js';
+import { debugLogger, noopLogger } from '../utils/logger.js';
+import { linguiBabelPlugin } from '../vite-plugin-lingui-babel.js';
+
+/**
+ * Integration tests for the linguiBabelPlugin with actual npm packages.
+ *
+ * These tests verify that:
+ * 1. The plugin discovery mechanism correctly identifies npm packages with dashboard extensions
+ * 2. The linguiBabelPlugin transforms Lingui macros in those discovered packages
+ *
+ * This addresses the bug where third-party npm packages (like @vendure-ee/*) with
+ * dashboard extensions using Lingui macros would fail to build because the macros
+ * weren't being transformed.
+ *
+ * @see LINGUI_BABEL_PLUGIN_BUG.md
+ */
+describe('linguiBabelPlugin with npm packages containing Lingui macros', () => {
+    const fixtureDir = join(__dirname, 'fixtures-lingui-npm-plugin');
+    const fakeNodeModules = join(fixtureDir, 'fake_node_modules');
+    const tempDir = join(__dirname, './__temp/lingui-npm-plugin');
+
+    it('should discover npm plugin and transform its Lingui macros', { timeout: 60_000 }, async () => {
+        // Clean up temp directory
+        await rm(tempDir, { recursive: true, force: true });
+
+        // Register tsconfig paths so the test can resolve the fake npm package
+        tsconfigPaths.register({
+            baseUrl: fakeNodeModules,
+            paths: {
+                '@test-scope/lingui-plugin': [join(fakeNodeModules, '@test-scope/lingui-plugin')],
+            },
+        });
+
+        // Step 1: Compile and discover plugins (like the real build process does)
+        const result = await compile({
+            outputPath: tempDir,
+            vendureConfigPath: join(fixtureDir, 'vendure-config.ts'),
+            logger: process.env.LOG ? debugLogger : noopLogger,
+            pluginPackageScanner: {
+                nodeModulesRoot: fakeNodeModules,
+            },
+        });
+
+        // Verify the plugin was discovered
+        expect(result.pluginInfo).toHaveLength(1);
+        expect(result.pluginInfo[0].name).toBe('LinguiTestPlugin');
+        expect(result.pluginInfo[0].dashboardEntryPath).toBe('./dashboard/index.tsx');
+        expect(result.pluginInfo[0].sourcePluginPath).toBeUndefined(); // npm package, not local
+
+        // Step 2: Extract the package path from the discovered plugin
+        // (This is what the linguiBabelPlugin does in configResolved)
+        const pluginPath = result.pluginInfo[0].pluginPath;
+        expect(pluginPath).toContain('@test-scope/lingui-plugin');
+
+        // Extract package name from path (simulating extractPackagePath)
+        const packageName = '@test-scope/lingui-plugin';
+
+        // Step 3: Create linguiBabelPlugin with the discovered package
+        const plugin = linguiBabelPlugin({
+            additionalPackagePaths: [packageName],
+        });
+
+        // Step 4: Read the actual dashboard file that contains Lingui macros
+        const dashboardFilePath = join(fakeNodeModules, '@test-scope/lingui-plugin/dashboard/index.tsx');
+        const dashboardCode = await readFile(dashboardFilePath, 'utf-8');
+
+        // Verify the file contains Lingui macros before transformation
+        expect(dashboardCode).toContain("from '@lingui/react/macro'");
+        expect(dashboardCode).toContain("from '@lingui/core/macro'");
+        expect(dashboardCode).toContain('<Trans>');
+        expect(dashboardCode).toContain('t`');
+
+        // Step 5: Transform the file using linguiBabelPlugin
+        // @ts-expect-error - transform expects a different context but works for testing
+        const transformed = await plugin.transform(dashboardCode, dashboardFilePath);
+
+        // Step 6: Verify the macros were transformed
+        expect(transformed).not.toBeNull();
+        expect(transformed?.code).toBeDefined();
+
+        // The transformed code should NOT contain macro imports
+        expect(transformed?.code).not.toContain("from '@lingui/react/macro'");
+        expect(transformed?.code).not.toContain("from '@lingui/core/macro'");
+
+        // The transformed code should contain the runtime imports instead
+        expect(transformed?.code).toContain('@lingui/react');
+        expect(transformed?.code).toContain('@lingui/core');
+    });
+
+    it('should NOT transform Lingui macros in undiscovered npm packages', { timeout: 60_000 }, async () => {
+        // Create plugin WITHOUT the package in additionalPackagePaths
+        // (simulating a package that wasn't discovered as a Vendure plugin)
+        const plugin = linguiBabelPlugin({
+            additionalPackagePaths: [], // Empty - no packages discovered
+        });
+
+        // Read the dashboard file
+        const dashboardFilePath = join(fakeNodeModules, '@test-scope/lingui-plugin/dashboard/index.tsx');
+        const dashboardCode = await readFile(dashboardFilePath, 'utf-8');
+
+        // Try to transform - should return null because package isn't in allowlist
+        // @ts-expect-error - transform expects a different context but works for testing
+        const transformed = await plugin.transform(dashboardCode, dashboardFilePath);
+
+        // Should be null - file was skipped because it's in node_modules and not discovered
+        expect(transformed).toBeNull();
+    });
+
+    it('should still transform @vendure/dashboard source files without discovery', async () => {
+        // Create plugin with no discovered packages
+        const plugin = linguiBabelPlugin();
+
+        const code = `
+import { Trans } from '@lingui/react/macro';
+export function MyComponent() {
+    return <Trans>Hello</Trans>;
+}
+`;
+        // Simulate a file from @vendure/dashboard/src
+        const id = join(fakeNodeModules, '@vendure/dashboard/src/components/Test.tsx');
+
+        // @ts-expect-error - transform expects a different context but works for testing
+        const transformed = await plugin.transform(code, id);
+
+        // Should transform because @vendure/dashboard/src is always allowed
+        expect(transformed).not.toBeNull();
+        expect(transformed?.code).not.toContain("from '@lingui/react/macro'");
+    });
+
+    it('should still transform local files without discovery', async () => {
+        // Create plugin with no discovered packages
+        const plugin = linguiBabelPlugin();
+
+        const code = `
+import { Trans } from '@lingui/react/macro';
+export function MyComponent() {
+    return <Trans>Hello</Trans>;
+}
+`;
+        // Simulate a local file (not in node_modules)
+        const id = '/path/to/project/src/plugins/my-plugin/dashboard/Test.tsx';
+
+        // @ts-expect-error - transform expects a different context but works for testing
+        const transformed = await plugin.transform(code, id);
+
+        // Should transform because local files are always allowed
+        expect(transformed).not.toBeNull();
+        expect(transformed?.code).not.toContain("from '@lingui/react/macro'");
+    });
+});

+ 110 - 8
packages/dashboard/vite/vite-plugin-lingui-babel.ts

@@ -1,6 +1,20 @@
 import * as babel from '@babel/core';
 import type { Plugin } from 'vite';
 
+import { CompileResult } from './utils/compiler.js';
+import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
+
+/**
+ * Options for the linguiBabelPlugin.
+ */
+export interface LinguiBabelPluginOptions {
+    /**
+     * For testing: manually specify package paths that should have Lingui macros transformed.
+     * In production, these are automatically discovered from the VendureConfig plugins.
+     */
+    additionalPackagePaths?: string[];
+}
+
 /**
  * @description
  * A custom Vite plugin that transforms Lingui macros in files using Babel instead of SWC.
@@ -19,20 +33,42 @@ import type { Plugin } from 'vite';
  * - `@vendure/dashboard/src` files (in node_modules for external projects)
  * - `packages/dashboard/src` files (in monorepo development)
  * - User's dashboard extension files (e.g., custom plugins using Lingui)
+ * - Third-party npm packages that provide dashboard extensions (discovered automatically)
  *
  * Files NOT processed:
- * - Other node_modules packages (they shouldn't contain Lingui macros)
+ * - Files that don't contain Lingui macro imports (fast check via string matching)
+ * - Non-JS/TS files
+ * - node_modules packages that are not discovered as Vendure plugins
  *
  * @see https://github.com/vendurehq/vendure/issues/3929
  * @see https://github.com/lingui/swc-plugin/issues/179
  */
-export function linguiBabelPlugin(): Plugin {
+export function linguiBabelPlugin(options?: LinguiBabelPluginOptions): Plugin {
+    // Paths of npm packages that should have Lingui macros transformed.
+    // This is populated from plugin discovery when transform is first called.
+    const allowedNodeModulesPackages = new Set<string>(options?.additionalPackagePaths ?? []);
+
+    // API reference to the config loader plugin (set in configResolved)
+    let configLoaderApi: ConfigLoaderApi | undefined;
+    // Cached result from config loader (set on first transform that needs it)
+    let configResult: CompileResult | undefined;
+
     return {
         name: 'vendure:lingui-babel',
         // Run BEFORE @vitejs/plugin-react so the macros are already transformed
         // when the react plugin processes the file
         enforce: 'pre',
 
+        configResolved({ plugins }) {
+            // Get reference to the config loader API.
+            // This doesn't load the config yet - that happens lazily in transform.
+            try {
+                configLoaderApi = getConfigLoaderApi(plugins);
+            } catch {
+                // configLoaderPlugin not available (e.g., plugin used standalone for testing)
+            }
+        },
+
         async transform(code, id) {
             // Strip query params for path matching (Vite adds ?v=xxx for cache busting)
             const cleanId = id.split('?')[0];
@@ -48,16 +84,45 @@ export function linguiBabelPlugin(): Plugin {
                 return null;
             }
 
-            // Skip node_modules files EXCEPT for @vendure/dashboard source
-            // This ensures:
-            // 1. Dashboard source files get transformed (both in monorepo and external projects)
-            // 2. User's extension files get transformed (not in node_modules)
-            // 3. Other node_modules packages are left alone
+            // Check if this file should be transformed
             if (cleanId.includes('node_modules')) {
+                // Always allow @vendure/dashboard source files
                 const isVendureDashboard =
                     cleanId.includes('@vendure/dashboard/src') || cleanId.includes('packages/dashboard/src');
+
                 if (!isVendureDashboard) {
-                    return null;
+                    // Load discovered plugins on first need (lazy loading with caching)
+                    if (configLoaderApi && !configResult) {
+                        try {
+                            configResult = await configLoaderApi.getVendureConfig();
+                            // Extract package paths from discovered npm plugins
+                            for (const plugin of configResult.pluginInfo) {
+                                if (!plugin.sourcePluginPath && plugin.pluginPath.includes('node_modules')) {
+                                    const packagePath = extractPackagePath(plugin.pluginPath);
+                                    if (packagePath) {
+                                        allowedNodeModulesPackages.add(packagePath);
+                                    }
+                                }
+                            }
+                        } catch (error) {
+                            // Log but continue - will use only manually specified paths
+                            // eslint-disable-next-line no-console
+                            console.warn('[vendure:lingui-babel] Failed to load plugin config:', error);
+                        }
+                    }
+
+                    // Check if this is from a discovered Vendure plugin package
+                    let isDiscoveredPlugin = false;
+                    for (const pkgPath of allowedNodeModulesPackages) {
+                        if (cleanId.includes(pkgPath)) {
+                            isDiscoveredPlugin = true;
+                            break;
+                        }
+                    }
+
+                    if (!isDiscoveredPlugin) {
+                        return null;
+                    }
                 }
             }
 
@@ -93,3 +158,40 @@ export function linguiBabelPlugin(): Plugin {
         },
     };
 }
+
+/**
+ * Extracts the npm package name from a full file path.
+ *
+ * Examples:
+ * - /path/to/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
+ * - /path/to/node_modules/some-plugin/lib/index.js -> some-plugin
+ * - /path/to/node_modules/.pnpm/@vendure-ee+plugin@1.0.0/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
+ */
+function extractPackagePath(filePath: string): string | undefined {
+    // Normalize path separators
+    const normalizedPath = filePath.replace(/\\/g, '/');
+
+    // Find the last occurrence of node_modules (handles pnpm structure)
+    const lastNodeModulesIndex = normalizedPath.lastIndexOf('node_modules/');
+    if (lastNodeModulesIndex === -1) {
+        return undefined;
+    }
+
+    const afterNodeModules = normalizedPath.slice(lastNodeModulesIndex + 'node_modules/'.length);
+
+    // Handle scoped packages (@scope/package)
+    if (afterNodeModules.startsWith('@')) {
+        const parts = afterNodeModules.split('/');
+        if (parts.length >= 2) {
+            return `${parts[0]}/${parts[1]}`;
+        }
+    } else {
+        // Unscoped package
+        const parts = afterNodeModules.split('/');
+        if (parts.length >= 1) {
+            return parts[0];
+        }
+    }
+
+    return undefined;
+}