vite-plugin-translations.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {
  2. createCompilationErrorMessage,
  3. createCompiledCatalog,
  4. getCatalogForFile,
  5. getCatalogs,
  6. } from '@lingui/cli/api';
  7. import { getConfig, LinguiConfigNormalized } from '@lingui/conf';
  8. import glob from 'fast-glob';
  9. import * as fs from 'fs';
  10. import * as path from 'path';
  11. import type { Plugin } from 'vite';
  12. import { PluginInfo } from './types.js';
  13. import { CompileResult } from './utils/compiler.js';
  14. import { getDashboardPaths } from './utils/get-dashboard-paths.js';
  15. import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
  16. type Catalog = Awaited<ReturnType<typeof getCatalogs>>[number];
  17. export interface TranslationsPluginOptions {
  18. /**
  19. * Array of paths to .po files to merge with built-in translations
  20. */
  21. externalPoFiles?: string[];
  22. /**
  23. * Path to the built-in locales directory
  24. */
  25. localesDir?: string;
  26. /**
  27. * Output path for merged translations within the build output (e.g., 'i18n')
  28. */
  29. outputPath?: string;
  30. packageRoot: string;
  31. }
  32. type PluginTranslation = {
  33. pluginRootPath: string;
  34. translations: string[];
  35. };
  36. const virtualModuleId = 'virtual:plugin-translations';
  37. const resolvedVirtualModuleId = `\0${virtualModuleId}`;
  38. /**
  39. * @description
  40. * This Vite plugin compiles the source .po files into JS bundles that can be loaded statically.
  41. *
  42. * It handles 2 modes: dev and build.
  43. *
  44. * - The dev case is handled in the `load` function using Vite virtual
  45. * modules to compile and return translations from plugins _only_, which then get merged with the built-in
  46. * translations in the `loadI18nMessages` function
  47. * - The build case loads both built-in and plugin translations, merges them, and outputs the compiled
  48. * files as .js files that can be statically consumed by the built app.
  49. *
  50. * @param options
  51. */
  52. export function translationsPlugin(options: TranslationsPluginOptions): Plugin {
  53. let configLoaderApi: ConfigLoaderApi;
  54. let loadVendureConfigResult: CompileResult;
  55. return {
  56. name: 'vendure:compile-translations',
  57. configResolved({ plugins }) {
  58. configLoaderApi = getConfigLoaderApi(plugins);
  59. },
  60. resolveId(id) {
  61. if (id === virtualModuleId) {
  62. return resolvedVirtualModuleId;
  63. }
  64. },
  65. async load(id) {
  66. if (id === resolvedVirtualModuleId) {
  67. this.debug('Loading plugin translations...');
  68. if (!loadVendureConfigResult) {
  69. loadVendureConfigResult = await configLoaderApi.getVendureConfig();
  70. }
  71. const { pluginInfo } = loadVendureConfigResult;
  72. const pluginTranslations = await getPluginTranslations(pluginInfo);
  73. const linguiConfig = getConfig({
  74. configPath: path.join(options.packageRoot, 'lingui.config.js'),
  75. });
  76. const catalogs = await getLinguiCatalogs(linguiConfig, pluginTranslations);
  77. const pluginFiles = pluginTranslations.flatMap(translation => translation.translations);
  78. const mergedMessageMap = await createMergedMessageMap({
  79. files: pluginFiles,
  80. packageRoot: options.packageRoot,
  81. catalogs,
  82. sourceLocale: linguiConfig.sourceLocale,
  83. });
  84. return `
  85. const translations = {
  86. ${[...mergedMessageMap.entries()]
  87. .map(([locale, messages]) => {
  88. const safeLocale = locale.replace(/-/g, '_');
  89. return `${safeLocale}: ${JSON.stringify(messages)}`;
  90. })
  91. .join(',\n')}
  92. };
  93. export default translations;
  94. `;
  95. }
  96. },
  97. // This runs at build-time only
  98. async generateBundle() {
  99. // This runs during the bundle generation phase - emit files directly to build output
  100. try {
  101. const { pluginInfo } = await configLoaderApi.getVendureConfig();
  102. // Get any plugin-provided .po files
  103. const pluginTranslations = await getPluginTranslations(pluginInfo);
  104. const pluginTranslationFiles = pluginTranslations.flatMap(p => p.translations);
  105. this.info(`Found ${pluginTranslationFiles.length} translation files from plugins`);
  106. this.debug(pluginTranslationFiles.join('\n'));
  107. await compileTranslations(options, pluginTranslations, this.emitFile);
  108. } catch (error) {
  109. this.error(
  110. `Translation plugin error: ${error instanceof Error ? error.message : String(error)}`,
  111. );
  112. }
  113. },
  114. };
  115. }
  116. async function getPluginTranslations(pluginInfo: PluginInfo[]): Promise<PluginTranslation[]> {
  117. const dashboardPaths = getDashboardPaths(pluginInfo);
  118. const pluginTranslations: PluginTranslation[] = [];
  119. for (const dashboardPath of dashboardPaths) {
  120. const poPatterns = path.join(dashboardPath, '**/*.po');
  121. const translations = await glob(poPatterns, {
  122. ignore: [
  123. // Standard test & doc files
  124. '**/node_modules/**/node_modules/**',
  125. '**/*.spec.js',
  126. '**/*.test.js',
  127. ],
  128. onlyFiles: true,
  129. absolute: true,
  130. followSymbolicLinks: false,
  131. stats: false,
  132. });
  133. pluginTranslations.push({
  134. pluginRootPath: dashboardPath,
  135. translations,
  136. });
  137. }
  138. return pluginTranslations;
  139. }
  140. async function compileTranslations(
  141. options: TranslationsPluginOptions,
  142. pluginTranslations: PluginTranslation[],
  143. emitFile: any,
  144. ) {
  145. const { localesDir = 'src/i18n/locales', outputPath = 'assets/i18n' } = options;
  146. const linguiConfig = getConfig({ configPath: path.join(options.packageRoot, 'lingui.config.js') });
  147. const resolvedLocalesDir = path.resolve(options.packageRoot, localesDir);
  148. const catalogs = await getLinguiCatalogs(linguiConfig, pluginTranslations);
  149. // Get all built-in .po files
  150. const builtInFiles = fs
  151. .readdirSync(resolvedLocalesDir)
  152. .filter(file => file.endsWith('.po'))
  153. .map(file => path.join(resolvedLocalesDir, file));
  154. const pluginFiles = pluginTranslations.flatMap(translation => translation.translations);
  155. const mergedMessageMap = await createMergedMessageMap({
  156. files: [...builtInFiles, ...pluginFiles],
  157. packageRoot: options.packageRoot,
  158. catalogs,
  159. sourceLocale: linguiConfig.sourceLocale,
  160. });
  161. for (const [locale, messages] of mergedMessageMap.entries()) {
  162. const { source: code, errors } = createCompiledCatalog(locale, messages, {
  163. namespace: 'es',
  164. pseudoLocale: linguiConfig.pseudoLocale,
  165. });
  166. if (errors.length) {
  167. const message = createCompilationErrorMessage(locale, errors);
  168. throw new Error(
  169. message +
  170. `These errors fail build because \`failOnCompileError=true\` in Lingui Vite plugin configuration.`,
  171. );
  172. }
  173. // Emit the compiled JavaScript file to the build output
  174. const outputFileName = path.posix.join(outputPath, `${locale}.js`);
  175. emitFile({
  176. type: 'asset',
  177. fileName: outputFileName,
  178. source: code,
  179. });
  180. }
  181. }
  182. async function getLinguiCatalogs(
  183. linguiConfig: LinguiConfigNormalized,
  184. pluginTranslations: PluginTranslation[],
  185. ) {
  186. for (const pluginTranslation of pluginTranslations) {
  187. if (pluginTranslation.translations.length === 0) {
  188. continue;
  189. }
  190. linguiConfig.catalogs?.push({
  191. path: pluginTranslation.translations[0]?.replace(/[a-z_-]+\.po$/, '{locale}') ?? '',
  192. include: [],
  193. });
  194. }
  195. return getCatalogs(linguiConfig);
  196. }
  197. async function createMergedMessageMap({
  198. files,
  199. packageRoot,
  200. catalogs,
  201. sourceLocale,
  202. }: {
  203. files: string[];
  204. packageRoot: string;
  205. catalogs: Catalog[];
  206. sourceLocale?: string;
  207. }): Promise<Map<string, Record<string, string>>> {
  208. const mergedMessageMap = new Map<string, Record<string, string>>();
  209. for (const file of files) {
  210. const catalogRelativePath = path.relative(packageRoot, file);
  211. const fileCatalog = getCatalogForFile(catalogRelativePath, catalogs);
  212. const { locale, catalog } = fileCatalog;
  213. const { messages } = await catalog.getTranslations(locale, {
  214. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  215. fallbackLocales: { default: sourceLocale! },
  216. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  217. sourceLocale: sourceLocale!,
  218. });
  219. const mergedMessages = mergedMessageMap.get(locale) ?? {};
  220. mergedMessageMap.set(locale, { ...mergedMessages, ...messages });
  221. }
  222. return mergedMessageMap;
  223. }