Browse Source

feat(ui-devkit): Allow stand-alone translation extensions

Relates to #264
Michael Bromley 5 years ago
parent
commit
7a70642936

+ 34 - 9
packages/ui-devkit/src/compiler/compile.ts

@@ -1,4 +1,5 @@
 /* tslint:disable:no-console */
+import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { AdminUiAppConfig, AdminUiAppDevModeConfig } from '@vendure/common/lib/shared-types';
 import { ChildProcess, spawn } from 'child_process';
 import { FSWatcher, watch as chokidarWatch } from 'chokidar';
@@ -7,11 +8,13 @@ import * as path from 'path';
 
 import { MODULES_OUTPUT_DIR } from './constants';
 import { setupScaffold } from './scaffold';
-import { AdminUiExtension, StaticAssetDefinition, UiExtensionCompilerOptions } from './types';
+import { getAllTranslationFiles, mergeExtensionTranslations } from './translations';
+import { Extension, StaticAssetDefinition, UiExtensionCompilerOptions } from './types';
 import {
     copyStaticAsset,
     copyUiDevkit,
     getStaticAssetPath,
+    isAdminUiExtension,
     normalizeExtensions,
     shouldUseYarn,
 } from './utils';
@@ -33,7 +36,7 @@ export function compileUiExtensions(
     }
 }
 
-function runCompileMode(outputPath: string, extensions: AdminUiExtension[]): AdminUiAppConfig {
+function runCompileMode(outputPath: string, extensions: Extension[]): AdminUiAppConfig {
     const cmd = shouldUseYarn() ? 'yarn' : 'npm';
     const distPath = path.join(outputPath, 'dist');
 
@@ -61,11 +64,7 @@ function runCompileMode(outputPath: string, extensions: AdminUiExtension[]): Adm
     };
 }
 
-function runWatchMode(
-    outputPath: string,
-    port: number,
-    extensions: AdminUiExtension[],
-): AdminUiAppDevModeConfig {
+function runWatchMode(outputPath: string, port: number, extensions: Extension[]): AdminUiAppDevModeConfig {
     const cmd = shouldUseYarn() ? 'yarn' : 'npm';
     const devkitPath = require.resolve('@vendure/ui-devkit');
     let buildProcess: ChildProcess;
@@ -76,7 +75,9 @@ function runWatchMode(
     const compile = () =>
         new Promise<void>(async (resolve, reject) => {
             await setupScaffold(outputPath, extensions);
-            const normalizedExtensions = normalizeExtensions(extensions);
+            const adminUiExtensions = extensions.filter(isAdminUiExtension);
+            const normalizedExtensions = normalizeExtensions(adminUiExtensions);
+            const allTranslationFiles = getAllTranslationFiles(extensions);
             buildProcess = spawn(cmd, ['run', 'start', `--port=${port}`], {
                 cwd: outputPath,
                 shell: true,
@@ -108,6 +109,18 @@ function runWatchMode(
                     }
                 }
             }
+            for (const translationFiles of Object.values(allTranslationFiles)) {
+                if (!translationFiles) {
+                    continue;
+                }
+                for (const file of translationFiles) {
+                    if (!watcher) {
+                        watcher = chokidarWatch(file);
+                    } else {
+                        watcher.add(file);
+                    }
+                }
+            }
 
             if (watcher) {
                 // watch the ui-devkit package files too
@@ -115,10 +128,11 @@ function runWatchMode(
             }
 
             if (watcher) {
-                const allStaticAssetDefs = extensions.reduce(
+                const allStaticAssetDefs = adminUiExtensions.reduce(
                     (defs, e) => [...defs, ...(e.staticAssets || [])],
                     [] as StaticAssetDefinition[],
                 );
+
                 watcher.on('change', async filePath => {
                     const extension = normalizedExtensions.find(e => filePath.includes(e.extensionPath));
                     if (extension) {
@@ -137,6 +151,17 @@ function runWatchMode(
                             return;
                         }
                     }
+                    for (const languageCode of Object.keys(allTranslationFiles)) {
+                        // tslint:disable-next-line:no-non-null-assertion
+                        const translationFiles = allTranslationFiles[languageCode as LanguageCode]!;
+                        for (const file of translationFiles) {
+                            if (filePath.includes(path.normalize(file))) {
+                                await mergeExtensionTranslations(outputPath, {
+                                    [languageCode]: translationFiles,
+                                });
+                            }
+                        }
+                    }
                 });
             }
             resolve();

+ 16 - 95
packages/ui-devkit/src/compiler/scaffold.ts

@@ -1,25 +1,33 @@
 /* tslint:disable:no-console */
-import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { spawn } from 'child_process';
 import * as fs from 'fs-extra';
-import glob from 'glob';
 import * as path from 'path';
 
 import { EXTENSION_ROUTES_FILE, MODULES_OUTPUT_DIR, SHARED_EXTENSIONS_FILE } from './constants';
+import { getAllTranslationFiles, mergeExtensionTranslations } from './translations';
 import {
     AdminUiExtension,
     AdminUiExtensionLazyModule,
     AdminUiExtensionSharedModule,
-    Translations,
+    Extension,
 } from './types';
-import { copyStaticAsset, copyUiDevkit, logger, normalizeExtensions, shouldUseYarn } from './utils';
-
-export async function setupScaffold(outputPath: string, extensions: AdminUiExtension[]) {
+import {
+    copyStaticAsset,
+    copyUiDevkit,
+    isAdminUiExtension,
+    logger,
+    normalizeExtensions,
+    shouldUseYarn,
+} from './utils';
+
+export async function setupScaffold(outputPath: string, extensions: Extension[]) {
     deleteExistingExtensionModules(outputPath);
     copySourceIfNotExists(outputPath);
-    const normalizedExtensions = normalizeExtensions(extensions);
+    const adminUiExtensions = extensions.filter(isAdminUiExtension);
+    const normalizedExtensions = normalizeExtensions(adminUiExtensions);
     await copyExtensionModules(outputPath, normalizedExtensions);
-    await mergeExtensionTranslations(outputPath, normalizedExtensions);
+    const allTranslationFiles = getAllTranslationFiles(extensions);
+    await mergeExtensionTranslations(outputPath, allTranslationFiles);
     copyUiDevkit(outputPath);
     try {
         await checkIfNgccWasRun();
@@ -110,93 +118,6 @@ function getModuleFilePath(
     return `./extensions/${id}/${path.basename(module.ngModuleFileName, '.ts')}`;
 }
 
-async function mergeExtensionTranslations(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
-    // First collect all globs by language
-    const allTranslations: { [languageCode in LanguageCode]?: string[] } = {};
-    for (const extension of extensions) {
-        for (const [languageCode, globPattern] of Object.entries(extension.translations)) {
-            const code = languageCode as LanguageCode;
-            if (globPattern) {
-                if (!allTranslations[code]) {
-                    allTranslations[code] = [globPattern];
-                } else {
-                    // tslint:disable-next-line:no-non-null-assertion
-                    allTranslations[code]!.push(globPattern);
-                }
-            }
-        }
-    }
-    // Now merge them into the final language-speicific json files
-    const i18nMessagesDir = path.join(outputPath, 'src/i18n-messages');
-    for (const [languageCode, globs] of Object.entries(allTranslations)) {
-        if (!globs) {
-            continue;
-        }
-        const translationFile = path.join(i18nMessagesDir, `${languageCode}.json`);
-        const translationBackupFile = path.join(i18nMessagesDir, `${languageCode}.json.bak`);
-
-        if (fs.existsSync(translationBackupFile)) {
-            // restore the original translations from the backup
-            await fs.copy(translationBackupFile, translationFile);
-        }
-        let translations: any = {};
-        if (fs.existsSync(translationFile)) {
-            // create a backup of the original (unextended) translations
-            await fs.copy(translationFile, translationBackupFile);
-            try {
-                translations = require(translationFile);
-            } catch (e) {
-                logger.error(`Could not load translation file: ${translationFile}`);
-                logger.error(e);
-            }
-        }
-
-        for (const pattern of globs) {
-            const files = glob.sync(pattern);
-            for (const file of files) {
-                try {
-                    const contents = require(file);
-                    translations = mergeTranslations(translations, contents);
-                } catch (e) {
-                    logger.error(`Could not load translation file: ${translationFile}`);
-                    logger.error(e);
-                }
-            }
-        }
-
-        // write the final translation files to disk
-        const sortedTranslations = sortTranslationKeys(translations);
-        await fs.writeFile(translationFile, JSON.stringify(sortedTranslations, null, 2), 'utf8');
-    }
-}
-
-/**
- * Merges the second set of translations into the first, returning a new translations
- * object.
- */
-function mergeTranslations(t1: Translations, t2: Translations): Translations {
-    const result = { ...t1 };
-    for (const [section, translations] of Object.entries(t2)) {
-        result[section] = {
-            ...t1[section],
-            ...translations,
-        };
-    }
-    return result;
-}
-
-function sortTranslationKeys(translations: Translations): Translations {
-    const result: Translations = {};
-    const sections = Object.keys(translations).sort();
-    for (const section of sections) {
-        const sortedTokens = Object.entries(translations[section])
-            .sort(([keyA], [keyB]) => (keyA < keyB ? -1 : 1))
-            .reduce((output, [key, val]) => ({ ...output, [key]: val }), {});
-        result[section] = sortedTokens;
-    }
-    return result;
-}
-
 /**
  * Copy the Admin UI sources & static assets to the outputPath if it does not already
  * exists there.

+ 122 - 0
packages/ui-devkit/src/compiler/translations.ts

@@ -0,0 +1,122 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import * as fs from 'fs-extra';
+import glob from 'glob';
+import * as path from 'path';
+
+import { Extension, Translations } from './types';
+import { logger } from './utils';
+
+/**
+ * Given an array of extensions, returns a map of languageCode to all files specified by the
+ * configured globs.
+ */
+export function getAllTranslationFiles(
+    extensions: Extension[],
+): { [languageCode in LanguageCode]?: string[] } {
+    // First collect all globs by language
+    const allTranslationsWithGlobs: { [languageCode in LanguageCode]?: string[] } = {};
+    for (const extension of extensions) {
+        for (const [languageCode, globPattern] of Object.entries(extension.translations || {})) {
+            const code = languageCode as LanguageCode;
+            if (globPattern) {
+                if (!allTranslationsWithGlobs[code]) {
+                    allTranslationsWithGlobs[code] = [globPattern];
+                } else {
+                    // tslint:disable-next-line:no-non-null-assertion
+                    allTranslationsWithGlobs[code]!.push(globPattern);
+                }
+            }
+        }
+    }
+
+    const allTranslationsWithFiles: { [languageCode in LanguageCode]?: string[] } = {};
+
+    for (const [languageCode, globs] of Object.entries(allTranslationsWithGlobs)) {
+        const code = languageCode as LanguageCode;
+        allTranslationsWithFiles[code] = [];
+        if (!globs) {
+            continue;
+        }
+        for (const pattern of globs) {
+            const files = glob.sync(pattern);
+            // tslint:disable-next-line:no-non-null-assertion
+            allTranslationsWithFiles[code]!.push(...files);
+        }
+    }
+    return allTranslationsWithFiles;
+}
+
+export async function mergeExtensionTranslations(
+    outputPath: string,
+    translationFiles: { [languageCode in LanguageCode]?: string[] },
+) {
+    // Now merge them into the final language-speicific json files
+    const i18nMessagesDir = path.join(outputPath, 'src/i18n-messages');
+    for (const [languageCode, files] of Object.entries(translationFiles)) {
+        if (!files) {
+            continue;
+        }
+        const translationFile = path.join(i18nMessagesDir, `${languageCode}.json`);
+        const translationBackupFile = path.join(i18nMessagesDir, `${languageCode}.json.bak`);
+
+        if (fs.existsSync(translationBackupFile)) {
+            // restore the original translations from the backup
+            await fs.copy(translationBackupFile, translationFile);
+        }
+        let translations: any = {};
+        if (fs.existsSync(translationFile)) {
+            // create a backup of the original (unextended) translations
+            await fs.copy(translationFile, translationBackupFile);
+            try {
+                translations = await fs.readJson(translationFile);
+            } catch (e) {
+                logger.error(`Could not load translation file: ${translationFile}`);
+                logger.error(e);
+            }
+        }
+
+        for (const file of files) {
+            try {
+                const contents = await fs.readJson(file);
+                translations = mergeTranslations(translations, contents);
+            } catch (e) {
+                logger.error(`Could not load translation file: ${translationFile}`);
+                logger.error(e);
+            }
+        }
+
+        // write the final translation files to disk
+        const sortedTranslations = sortTranslationKeys(translations);
+        await fs.writeFile(translationFile, JSON.stringify(sortedTranslations, null, 2), 'utf8');
+    }
+}
+
+/**
+ * Sorts the contents of the translation files so the sections & keys are alphabetical.
+ */
+function sortTranslationKeys(translations: Translations): Translations {
+    const result: Translations = {};
+    const sections = Object.keys(translations).sort();
+    for (const section of sections) {
+        const sortedTokens = Object.entries(translations[section])
+            .sort(([keyA], [keyB]) => (keyA < keyB ? -1 : 1))
+            .reduce((output, [key, val]) => ({ ...output, [key]: val }), {});
+        result[section] = sortedTokens;
+    }
+    return result;
+}
+
+/**
+ * Merges the second set of translations into the first, returning a new translations
+ * object.
+ */
+function mergeTranslations(t1: Translations, t2: Translations): Translations {
+    const result = { ...t1 };
+    for (const [section, translations] of Object.entries(t2)) {
+        result[section] = {
+            ...t1[section],
+            ...translations,
+        };
+    }
+    return result;
+}

+ 33 - 20
packages/ui-devkit/src/compiler/types.ts

@@ -1,5 +1,34 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+export type Extension = AdminUiExtension | TranslationExtension;
+
+/**
+ * @description
+ * Defines extensions to the Admin UI translations. Can be used as a stand-alone extension definition which only adds translations
+ * without adding new UI functionality, or as part of a full {@link AdminUiExtension}.
+ *
+ * @docsCategory UiDevkit
+ * @docsPage AdminUiExtension
+ */
+export interface TranslationExtension {
+    /**
+     * @description
+     * Optional object defining any translation files for the Admin UI. The value should be an object with
+     * the key as a 2-character [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes),
+     * and the value being a [glob](https://github.com/isaacs/node-glob) for any relevant
+     * translation files in JSON format.
+     *
+     * @example
+     * ```TypeScript
+     * translations: {
+     *   en: path.join(__dirname, 'translations/*.en.json'),
+     *   de: path.join(__dirname, 'translations/*.de.json'),
+     * }
+     * ```
+     */
+    translations: { [languageCode in LanguageCode]?: string };
+}
+
 /**
  * @description
  * Defines extensions to the Admin UI application by specifying additional
@@ -12,7 +41,7 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
  * @docsCategory UiDevkit
  * @docsPage AdminUiExtension
  */
-export interface AdminUiExtension {
+export interface AdminUiExtension extends Partial<TranslationExtension> {
     /**
      * @description
      * An optional ID for the extension module. Only used internally for generating
@@ -39,22 +68,6 @@ export interface AdminUiExtension {
      * directory.
      */
     staticAssets?: StaticAssetDefinition[];
-
-    /**
-     * @description
-     * Optional object defining any translation files for the Admin UI. The value should be an object with
-     * the key as a 2-character ISO 639-1 language code, and the value being a [glob](https://github.com/isaacs/node-glob) for any relevant
-     * translation files in JSON format.
-     *
-     * @example
-     * ```TypeScript
-     * translations: {
-     *   en: path.join(__dirname, 'translations/*.en.json'),
-     *   de: path.join(__dirname, 'translations/*.de.json'),
-     * }
-     * ```
-     */
-    translations?: { [languageCode in LanguageCode]?: string };
 }
 
 /**
@@ -141,10 +154,10 @@ export interface UiExtensionCompilerOptions {
     outputPath: string;
     /**
      * @description
-     * An array of objects which configure extension Angular modules
-     * to be compiled into and made available by the AdminUi application.
+     * An array of objects which configure Angular modules and/or
+     * translations with which to extend the Admin UI.
      */
-    extensions: AdminUiExtension[];
+    extensions: Array<AdminUiExtension | TranslationExtension>;
     /**
      * @description
      * Set to `true` in order to compile the Admin UI in development mode (using the Angular CLI

+ 8 - 2
packages/ui-devkit/src/compiler/utils.ts

@@ -1,12 +1,14 @@
 /* tslint:disable:no-console */
-import * as chalk from 'chalk';
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import chalk from 'chalk';
 import { execSync } from 'child_process';
 import { createHash } from 'crypto';
 import * as fs from 'fs-extra';
+import glob from 'glob';
 import * as path from 'path';
 
 import { STATIC_ASSETS_OUTPUT_DIR } from './constants';
-import { AdminUiExtension, StaticAssetDefinition } from './types';
+import { AdminUiExtension, Extension, StaticAssetDefinition, Translations } from './types';
 
 export const logger = {
     log: (message: string) => console.log(chalk.green(message)),
@@ -88,3 +90,7 @@ export function normalizeExtensions(extensions?: AdminUiExtension[]): Array<Requ
         return { staticAssets: [], translations: {}, ...e, id };
     });
 }
+
+export function isAdminUiExtension(input: Extension): input is AdminUiExtension {
+    return input.hasOwnProperty('extensionPath');
+}