Browse Source

feat(dashboard): Support localization for dashboard extensions (#3962)

Michael Bromley 2 months ago
parent
commit
112cb9d9e8

+ 94 - 0
docs/docs/guides/extending-the-dashboard/localization/index.md

@@ -0,0 +1,94 @@
+---
+title: 'Localization'
+---
+
+:::note
+Support for localization of Dashboard extensions was added in v3.5.1
+:::
+
+The Dashboard uses [Lingui](https://lingui.dev/), which provides a powerful i18n solution for React:
+
+- ICU MessageFormat support
+- Automatic message extraction
+- TypeScript integration
+- Pluralization support
+- Compile-time optimization
+
+## Wrap your strings
+
+First you'll need to wrap any strings that need to be localized:
+
+```tsx
+import { Trans, useLingui } from '@lingui/react/macro';
+
+function MyComponent() {
+    const { t } = useLingui();
+
+    return (
+        <div>
+            <h1>
+                // highlight-next-line
+                <Trans>Welcome to Dashboard</Trans>
+            </h1>
+            // highlight-next-line
+            <p>{t`Click here to continue`}</p>
+        </div>
+    );
+}
+```
+
+You will mainly make use of the [Trans component](https://lingui.dev/ref/react#trans)
+and the [useLingui hook](https://lingui.dev/ref/react#uselingui).
+
+## Extract translations
+
+Create a `lingui.config.js` file in your project root, with references to any plugins that need to be localized:
+
+```js title="lingui.config.js
+import { defineConfig } from '@lingui/cli';
+
+export default defineConfig({
+    sourceLocale: 'en',
+    // Add any locales you wish to support
+    locales: ['en', 'de'],
+    catalogs: [
+        // For each plugin you want to localize, add a catalog entry
+        {
+            // This is the output location of the generated .po files
+            path: '<rootDir>/src/plugins/reviews/dashboard/i18n/{locale}',
+            // This is the pattern that tells Lingui which files to scan
+            // to extract translation strings
+            include: ['<rootDir>/src/plugins/reviews/dashboard/**'],
+        },
+    ],
+});
+```
+
+Then extract the translations:
+
+```bash
+npx lingui extract
+```
+
+This will output the given locale files in the directories specified in the config file above.
+In this case:
+
+```
+src/
+└── plugins/
+    └── reviews/
+        └── dashboard/
+            └── i18n/
+                ├── en.po
+                └── de.po
+```
+
+Since we set the "sourceLocale" to be "en", the `en.po` file will already be complete. You'll then need to
+open up the `de.po` file and add German translations for each of the strings, by filling out the empty `msgstr` values:
+
+```po title="de.po"
+#: test-plugins/reviews/dashboard/review-list.tsx:51
+msgid "Welcome to Dashboard"
+// highlight-next-line
+msgstr "Willkommen zum Dashboard"
+```

+ 1 - 0
docs/sidebars.js

@@ -177,6 +177,7 @@ const sidebars = {
                 'guides/extending-the-dashboard/alerts/index',
                 'guides/extending-the-dashboard/data-fetching/index',
                 'guides/extending-the-dashboard/theming/index',
+                'guides/extending-the-dashboard/localization/index',
                 'guides/extending-the-dashboard/deployment/index',
                 'guides/extending-the-dashboard/tech-stack/index',
                 'guides/extending-the-dashboard/migration/index',

+ 4 - 1
packages/dashboard/src/lib/lib/load-i18n-messages.ts

@@ -12,6 +12,9 @@ export async function loadI18nMessages(locale: string): Promise<Messages> {
     } else {
         // In dev mode we allow the dynamic import behaviour
         const { messages } = await import(`../../i18n/locales/${locale}.po`);
-        return messages;
+        const pluginTranslations = await import('virtual:plugin-translations');
+        const safeLocale = locale.replace(/-/g, '_');
+        const pluginTranslationsForLocale = pluginTranslations[safeLocale] ?? {};
+        return { ...messages, ...pluginTranslationsForLocale };
     }
 }

+ 28 - 0
packages/dashboard/vite/utils/get-dashboard-paths.ts

@@ -0,0 +1,28 @@
+import path from 'path';
+
+import { PluginInfo } from '../types.js';
+
+/**
+ * Returns an array of the paths to plugins, based on the info provided by the ConfigLoaderApi.
+ */
+export function getDashboardPaths(pluginInfo: PluginInfo[]) {
+    return (
+        pluginInfo
+            ?.flatMap(({ dashboardEntryPath, sourcePluginPath, pluginPath }) => {
+                if (!dashboardEntryPath) {
+                    return [];
+                }
+                const sourcePaths = [];
+                if (sourcePluginPath) {
+                    sourcePaths.push(
+                        path.join(path.dirname(sourcePluginPath), path.dirname(dashboardEntryPath)),
+                    );
+                }
+                if (pluginPath) {
+                    sourcePaths.push(path.join(path.dirname(pluginPath), path.dirname(dashboardEntryPath)));
+                }
+                return sourcePaths;
+            })
+            .filter(x => x != null) ?? []
+    );
+}

+ 2 - 24
packages/dashboard/vite/vite-plugin-tailwind-source.ts

@@ -1,7 +1,7 @@
-import path from 'path';
 import { Plugin } from 'vite';
 
 import { CompileResult } from './utils/compiler.js';
+import { getDashboardPaths } from './utils/get-dashboard-paths.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 /**
@@ -25,29 +25,7 @@ export function dashboardTailwindSourcePlugin(): Plugin {
                     loadVendureConfigResult = await configLoaderApi.getVendureConfig();
                 }
                 const { pluginInfo } = loadVendureConfigResult;
-                const dashboardExtensionDirs =
-                    pluginInfo
-                        ?.flatMap(({ dashboardEntryPath, sourcePluginPath, pluginPath }) => {
-                            if (!dashboardEntryPath) {
-                                return [];
-                            }
-                            const sourcePaths = [];
-                            if (sourcePluginPath) {
-                                sourcePaths.push(
-                                    path.join(
-                                        path.dirname(sourcePluginPath),
-                                        path.dirname(dashboardEntryPath),
-                                    ),
-                                );
-                            }
-                            if (pluginPath) {
-                                sourcePaths.push(
-                                    path.join(path.dirname(pluginPath), path.dirname(dashboardEntryPath)),
-                                );
-                            }
-                            return sourcePaths;
-                        })
-                        .filter(x => x != null) ?? [];
+                const dashboardExtensionDirs = getDashboardPaths(pluginInfo);
                 const sources = dashboardExtensionDirs
                     .map(extension => {
                         return `@source '${extension}';`;

+ 198 - 60
packages/dashboard/vite/vite-plugin-translations.ts

@@ -4,11 +4,19 @@ import {
     getCatalogForFile,
     getCatalogs,
 } from '@lingui/cli/api';
-import { getConfig } from '@lingui/conf';
+import { getConfig, LinguiConfigNormalized } from '@lingui/conf';
+import glob from 'fast-glob';
 import * as fs from 'fs';
 import * as path from 'path';
 import type { Plugin } from 'vite';
 
+import { PluginInfo } from './types.js';
+import { CompileResult } from './utils/compiler.js';
+import { getDashboardPaths } from './utils/get-dashboard-paths.js';
+import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
+
+type Catalog = Awaited<ReturnType<typeof getCatalogs>>[number];
+
 export interface TranslationsPluginOptions {
     /**
      * Array of paths to .po files to merge with built-in translations
@@ -25,80 +33,87 @@ export interface TranslationsPluginOptions {
     packageRoot: string;
 }
 
-type TranslationFile = {
-    name: string;
-    path: string;
+type PluginTranslation = {
+    pluginRootPath: string;
+    translations: string[];
 };
 
+const virtualModuleId = 'virtual:plugin-translations';
+const resolvedVirtualModuleId = `\0${virtualModuleId}`;
+
 /**
  * @description
- * This Vite plugin compiles
+ * This Vite plugin compiles the source .po files into JS bundles that can be loaded statically.
+ *
+ * It handles 2 modes: dev and build.
+ *
+ * - The dev case is handled in the `load` function using Vite virtual
+ * modules to compile and return translations from plugins _only_, which then get merged with the built-in
+ * translations in the `loadI18nMessages` function
+ * - The build case loads both built-in and plugin translations, merges them, and outputs the compiled
+ * files as .js files that can be statically consumed by the built app.
+ *
  * @param options
  */
 export function translationsPlugin(options: TranslationsPluginOptions): Plugin {
-    const { externalPoFiles = [], localesDir = 'src/i18n/locales', outputPath = 'assets/i18n' } = options;
+    let configLoaderApi: ConfigLoaderApi;
+    let loadVendureConfigResult: CompileResult;
 
-    const linguiConfig = getConfig({ configPath: path.join(options.packageRoot, 'lingui.config.js') });
-
-    const catalogsPromise = getCatalogs(linguiConfig);
-
-    async function compileTranslations(files: TranslationFile[], emitFile: any) {
-        const catalogs = await catalogsPromise;
-        for (const file of files) {
-            const catalogRelativePath = path.relative(options.packageRoot, file.path);
-            const fileCatalog = getCatalogForFile(catalogRelativePath, catalogs);
-
-            const { locale, catalog } = fileCatalog;
-
-            const { messages } = await catalog.getTranslations(locale, {
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                fallbackLocales: { default: linguiConfig.sourceLocale! },
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                sourceLocale: linguiConfig.sourceLocale!,
-            });
-
-            const { source: code, errors } = createCompiledCatalog(locale, messages, {
-                namespace: 'es',
-                pseudoLocale: linguiConfig.pseudoLocale,
-            });
-
-            if (errors.length) {
-                const message = createCompilationErrorMessage(locale, errors);
-                throw new Error(
-                    message +
-                        `These errors fail build because \`failOnCompileError=true\` in Lingui Vite plugin configuration.`,
-                );
+    return {
+        name: 'vendure:compile-translations',
+        configResolved({ plugins }) {
+            configLoaderApi = getConfigLoaderApi(plugins);
+        },
+        resolveId(id) {
+            if (id === virtualModuleId) {
+                return resolvedVirtualModuleId;
             }
+        },
+        async load(id) {
+            if (id === resolvedVirtualModuleId) {
+                this.debug('Loading plugin translations...');
 
-            // Emit the compiled JavaScript file to the build output
-            const outputFileName = path.posix.join(outputPath, `${locale}.js`);
-            emitFile({
-                type: 'asset',
-                fileName: outputFileName,
-                source: code,
-            });
-        }
-    }
+                if (!loadVendureConfigResult) {
+                    loadVendureConfigResult = await configLoaderApi.getVendureConfig();
+                }
 
-    return {
-        name: 'vendure:compile-translations',
+                const { pluginInfo } = loadVendureConfigResult;
+                const pluginTranslations = await getPluginTranslations(pluginInfo);
+                const linguiConfig = getConfig({
+                    configPath: path.join(options.packageRoot, 'lingui.config.js'),
+                });
+                const catalogs = await getLinguiCatalogs(linguiConfig, pluginTranslations);
+
+                const pluginFiles = pluginTranslations.flatMap(translation => translation.translations);
+
+                const mergedMessageMap = await createMergedMessageMap({
+                    files: pluginFiles,
+                    packageRoot: options.packageRoot,
+                    catalogs,
+                    sourceLocale: linguiConfig.sourceLocale,
+                });
+                return `
+                        ${[...mergedMessageMap.entries()]
+                            .map(([locale, messages]) => {
+                                const safeLocale = locale.replace(/-/g, '_');
+                                return `export const ${safeLocale} = ${JSON.stringify(messages)}`;
+                            })
+                            .join('\n')}
+                `;
+            }
+        },
+        // This runs at build-time only
         async generateBundle() {
             // This runs during the bundle generation phase - emit files directly to build output
             try {
-                const resolvedLocalesDir = path.resolve(options.packageRoot, localesDir);
+                const { pluginInfo } = await configLoaderApi.getVendureConfig();
 
-                // Get all built-in .po files
-                const builtInFiles = fs
-                    .readdirSync(resolvedLocalesDir)
-                    .filter(file => file.endsWith('.po'))
-                    .map(file => ({
-                        name: file,
-                        path: path.join(resolvedLocalesDir, file),
-                    }));
-
-                await compileTranslations(builtInFiles, this.emitFile);
-
-                this.info(`✓ Processed ${builtInFiles.length} translation files to ${outputPath}`);
+                // Get any plugin-provided .po files
+                const pluginTranslations = await getPluginTranslations(pluginInfo);
+                const pluginTranslationFiles = pluginTranslations.flatMap(p => p.translations);
+                this.info(`Found ${pluginTranslationFiles.length} translation files from plugins`);
+                this.debug(pluginTranslationFiles.join('\n'));
+                await compileTranslations(options, pluginTranslations, this.emitFile);
             } catch (error) {
                 this.error(
                     `Translation plugin error: ${error instanceof Error ? error.message : String(error)}`,
@@ -107,3 +122,126 @@ export function translationsPlugin(options: TranslationsPluginOptions): Plugin {
         },
     };
 }
+
+async function getPluginTranslations(pluginInfo: PluginInfo[]): Promise<PluginTranslation[]> {
+    const dashboardPaths = getDashboardPaths(pluginInfo);
+    const pluginTranslations: PluginTranslation[] = [];
+    for (const dashboardPath of dashboardPaths) {
+        const poPatterns = path.join(dashboardPath, '**/*.po');
+        const translations = await glob(poPatterns, {
+            ignore: [
+                // Standard test & doc files
+                '**/node_modules/**/node_modules/**',
+                '**/*.spec.js',
+                '**/*.test.js',
+            ],
+            onlyFiles: true,
+            absolute: true,
+            followSymbolicLinks: false,
+            stats: false,
+        });
+        pluginTranslations.push({
+            pluginRootPath: dashboardPath,
+            translations,
+        });
+    }
+    return pluginTranslations;
+}
+
+async function compileTranslations(
+    options: TranslationsPluginOptions,
+    pluginTranslations: PluginTranslation[],
+    emitFile: any,
+) {
+    const { localesDir = 'src/i18n/locales', outputPath = 'assets/i18n' } = options;
+    const linguiConfig = getConfig({ configPath: path.join(options.packageRoot, 'lingui.config.js') });
+    const resolvedLocalesDir = path.resolve(options.packageRoot, localesDir);
+    const catalogs = await getLinguiCatalogs(linguiConfig, pluginTranslations);
+
+    // Get all built-in .po files
+    const builtInFiles = fs
+        .readdirSync(resolvedLocalesDir)
+        .filter(file => file.endsWith('.po'))
+        .map(file => path.join(resolvedLocalesDir, file));
+
+    const pluginFiles = pluginTranslations.flatMap(translation => translation.translations);
+
+    const mergedMessageMap = await createMergedMessageMap({
+        files: [...builtInFiles, ...pluginFiles],
+        packageRoot: options.packageRoot,
+        catalogs,
+        sourceLocale: linguiConfig.sourceLocale,
+    });
+
+    for (const [locale, messages] of mergedMessageMap.entries()) {
+        const { source: code, errors } = createCompiledCatalog(locale, messages, {
+            namespace: 'es',
+            pseudoLocale: linguiConfig.pseudoLocale,
+        });
+
+        if (errors.length) {
+            const message = createCompilationErrorMessage(locale, errors);
+            throw new Error(
+                message +
+                    `These errors fail build because \`failOnCompileError=true\` in Lingui Vite plugin configuration.`,
+            );
+        }
+
+        // Emit the compiled JavaScript file to the build output
+        const outputFileName = path.posix.join(outputPath, `${locale}.js`);
+        emitFile({
+            type: 'asset',
+            fileName: outputFileName,
+            source: code,
+        });
+    }
+}
+
+async function getLinguiCatalogs(
+    linguiConfig: LinguiConfigNormalized,
+    pluginTranslations: PluginTranslation[],
+) {
+    for (const pluginTranslation of pluginTranslations) {
+        if (pluginTranslation.translations.length === 0) {
+            continue;
+        }
+        linguiConfig.catalogs?.push({
+            path: pluginTranslation.translations[0]?.replace(/[a-z_-]+\.po$/, '{locale}') ?? '',
+            include: [],
+        });
+    }
+    return getCatalogs(linguiConfig);
+}
+
+async function createMergedMessageMap({
+    files,
+    packageRoot,
+    catalogs,
+    sourceLocale,
+}: {
+    files: string[];
+    packageRoot: string;
+    catalogs: Catalog[];
+    sourceLocale?: string;
+}): Promise<Map<string, Record<string, string>>> {
+    const mergedMessageMap = new Map<string, Record<string, string>>();
+
+    for (const file of files) {
+        const catalogRelativePath = path.relative(packageRoot, file);
+        const fileCatalog = getCatalogForFile(catalogRelativePath, catalogs);
+
+        const { locale, catalog } = fileCatalog;
+
+        const { messages } = await catalog.getTranslations(locale, {
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            fallbackLocales: { default: sourceLocale! },
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            sourceLocale: sourceLocale!,
+        });
+
+        const mergedMessages = mergedMessageMap.get(locale) ?? {};
+        mergedMessageMap.set(locale, { ...mergedMessages, ...messages });
+    }
+
+    return mergedMessageMap;
+}

+ 12 - 0
packages/dev-server/lingui.config.js

@@ -0,0 +1,12 @@
+import { defineConfig } from '@lingui/cli';
+
+export default defineConfig({
+    sourceLocale: 'en',
+    locales: ['en', 'de'],
+    catalogs: [
+        {
+            path: '<rootDir>/test-plugins/reviews/dashboard/i18n/{locale}',
+            include: ['<rootDir>/test-plugins/reviews/dashboard/**'],
+        },
+    ],
+});

+ 12 - 0
packages/dev-server/test-plugins/reviews/dashboard/i18n/de.po

@@ -0,0 +1,12 @@
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2025-11-12 09:42+0100\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: de\n"
+
+#: test-plugins/reviews/dashboard/review-list.tsx:51
+msgid "Product Reviews"
+msgstr "Produkt Bewertungen"

+ 12 - 0
packages/dev-server/test-plugins/reviews/dashboard/i18n/en.po

@@ -0,0 +1,12 @@
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2025-11-12 09:42+0100\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: en\n"
+
+#: test-plugins/reviews/dashboard/review-list.tsx:51
+msgid "Product Reviews"
+msgstr "Product Reviews"

+ 2 - 1
packages/dev-server/test-plugins/reviews/dashboard/review-list.tsx

@@ -1,4 +1,5 @@
 import { graphql } from '@/graphql/graphql';
+import { Trans } from '@lingui/react/macro';
 import { DashboardRouteDefinition, DetailPageButton, ListPage } from '@vendure/dashboard';
 
 const getReviewList = graphql(`
@@ -47,7 +48,7 @@ export const reviewList: DashboardRouteDefinition = {
     component: route => (
         <ListPage
             pageId="review-list"
-            title="Product Reviews"
+            title={<Trans>Product Reviews</Trans>}
             listQuery={getReviewList}
             route={route}
             defaultVisibility={{