vite-plugin-lingui-babel.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import * as babel from '@babel/core';
  2. import type { Plugin } from 'vite';
  3. import { CompileResult } from './utils/compiler.js';
  4. import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
  5. /**
  6. * Options for the linguiBabelPlugin.
  7. */
  8. export interface LinguiBabelPluginOptions {
  9. /**
  10. * For testing: manually specify package paths that should have Lingui macros transformed.
  11. * In production, these are automatically discovered from the VendureConfig plugins.
  12. */
  13. additionalPackagePaths?: string[];
  14. }
  15. /**
  16. * @description
  17. * A custom Vite plugin that transforms Lingui macros in files using Babel instead of SWC.
  18. *
  19. * This plugin solves a critical compatibility issue with SWC plugins:
  20. * - SWC plugins are compiled Wasm binaries that require exact version matching with `@swc/core`
  21. * - When users have different SWC versions in their projects (e.g., from Next.js, Nx, etc.),
  22. * the Lingui SWC plugin fails with "failed to invoke plugin" errors
  23. * - Babel has no such binary compatibility issues, making it much more reliable for library code
  24. *
  25. * The plugin runs BEFORE `@vitejs/plugin-react` and transforms files containing Lingui macros
  26. * (imports from `@lingui/core/macro` or `@lingui/react/macro`) using the Babel-based
  27. * `@lingui/babel-plugin-lingui-macro`.
  28. *
  29. * Files processed:
  30. * - `@vendure/dashboard/src` files (in node_modules for external projects)
  31. * - `packages/dashboard/src` files (in monorepo development)
  32. * - User's dashboard extension files (e.g., custom plugins using Lingui)
  33. * - Third-party npm packages that provide dashboard extensions (discovered automatically)
  34. *
  35. * Files NOT processed:
  36. * - Files that don't contain Lingui macro imports (fast check via string matching)
  37. * - Non-JS/TS files
  38. * - node_modules packages that are not discovered as Vendure plugins
  39. *
  40. * @see https://github.com/vendurehq/vendure/issues/3929
  41. * @see https://github.com/lingui/swc-plugin/issues/179
  42. */
  43. export function linguiBabelPlugin(options?: LinguiBabelPluginOptions): Plugin {
  44. // Paths of npm packages that should have Lingui macros transformed.
  45. // This is populated from plugin discovery when transform is first called.
  46. const allowedNodeModulesPackages = new Set<string>(options?.additionalPackagePaths ?? []);
  47. // API reference to the config loader plugin (set in configResolved)
  48. let configLoaderApi: ConfigLoaderApi | undefined;
  49. // Cached result from config loader (set on first transform that needs it)
  50. let configResult: CompileResult | undefined;
  51. return {
  52. name: 'vendure:lingui-babel',
  53. // Run BEFORE @vitejs/plugin-react so the macros are already transformed
  54. // when the react plugin processes the file
  55. enforce: 'pre',
  56. configResolved({ plugins }) {
  57. // Get reference to the config loader API.
  58. // This doesn't load the config yet - that happens lazily in transform.
  59. try {
  60. configLoaderApi = getConfigLoaderApi(plugins);
  61. } catch {
  62. // configLoaderPlugin not available (e.g., plugin used standalone for testing)
  63. }
  64. },
  65. async transform(code, id) {
  66. // Strip query params for path matching (Vite adds ?v=xxx for cache busting)
  67. const cleanId = id.split('?')[0];
  68. // Only process TypeScript/JavaScript files
  69. if (!/\.[tj]sx?$/.test(cleanId)) {
  70. return null;
  71. }
  72. // Only process files that actually contain Lingui macro imports
  73. // This is a fast check to avoid running Babel on files that don't need it
  74. if (!code.includes('@lingui/') || !code.includes('/macro')) {
  75. return null;
  76. }
  77. // Check if this file should be transformed
  78. if (cleanId.includes('node_modules')) {
  79. // Always allow @vendure/dashboard source files
  80. const isVendureDashboard =
  81. cleanId.includes('@vendure/dashboard/src') || cleanId.includes('packages/dashboard/src');
  82. if (!isVendureDashboard) {
  83. // Load discovered plugins on first need (lazy loading with caching)
  84. if (configLoaderApi && !configResult) {
  85. try {
  86. configResult = await configLoaderApi.getVendureConfig();
  87. // Extract package paths from discovered npm plugins
  88. for (const plugin of configResult.pluginInfo) {
  89. if (!plugin.sourcePluginPath && plugin.pluginPath.includes('node_modules')) {
  90. const packagePath = extractPackagePath(plugin.pluginPath);
  91. if (packagePath) {
  92. allowedNodeModulesPackages.add(packagePath);
  93. }
  94. }
  95. }
  96. } catch (error) {
  97. // Log but continue - will use only manually specified paths
  98. // eslint-disable-next-line no-console
  99. console.warn('[vendure:lingui-babel] Failed to load plugin config:', error);
  100. }
  101. }
  102. // Check if this is from a discovered Vendure plugin package
  103. let isDiscoveredPlugin = false;
  104. for (const pkgPath of allowedNodeModulesPackages) {
  105. if (cleanId.includes(pkgPath)) {
  106. isDiscoveredPlugin = true;
  107. break;
  108. }
  109. }
  110. if (!isDiscoveredPlugin) {
  111. return null;
  112. }
  113. }
  114. }
  115. try {
  116. const result = await babel.transformAsync(code, {
  117. filename: id,
  118. presets: [
  119. ['@babel/preset-typescript', { isTSX: true, allExtensions: true }],
  120. ['@babel/preset-react', { runtime: 'automatic' }],
  121. ],
  122. plugins: ['@lingui/babel-plugin-lingui-macro'],
  123. sourceMaps: true,
  124. // Don't look for babel config files - we want to control the config completely
  125. configFile: false,
  126. babelrc: false,
  127. });
  128. if (!result?.code) {
  129. return null;
  130. }
  131. return {
  132. code: result.code,
  133. map: result.map,
  134. };
  135. } catch (error) {
  136. // Log the error but don't crash - let the build continue
  137. // The lingui vite plugin will catch untransformed macros later
  138. // eslint-disable-next-line no-console
  139. console.error(`[vendure:lingui-babel] Failed to transform ${id}:`, error);
  140. return null;
  141. }
  142. },
  143. };
  144. }
  145. /**
  146. * Extracts the npm package name from a full file path.
  147. *
  148. * Examples:
  149. * - /path/to/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
  150. * - /path/to/node_modules/some-plugin/lib/index.js -> some-plugin
  151. * - /path/to/node_modules/.pnpm/@vendure-ee+plugin@1.0.0/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
  152. */
  153. function extractPackagePath(filePath: string): string | undefined {
  154. // Normalize path separators
  155. const normalizedPath = filePath.replace(/\\/g, '/');
  156. // Find the last occurrence of node_modules (handles pnpm structure)
  157. const lastNodeModulesIndex = normalizedPath.lastIndexOf('node_modules/');
  158. if (lastNodeModulesIndex === -1) {
  159. return undefined;
  160. }
  161. const afterNodeModules = normalizedPath.slice(lastNodeModulesIndex + 'node_modules/'.length);
  162. // Handle scoped packages (@scope/package)
  163. if (afterNodeModules.startsWith('@')) {
  164. const parts = afterNodeModules.split('/');
  165. if (parts.length >= 2) {
  166. return `${parts[0]}/${parts[1]}`;
  167. }
  168. } else {
  169. // Unscoped package
  170. const parts = afterNodeModules.split('/');
  171. if (parts.length >= 1) {
  172. return parts[0];
  173. }
  174. }
  175. return undefined;
  176. }