Преглед изворни кода

feat(ui-devkit): Allow custom i18n files to compiled into the Admin UI

Closes #264
Michael Bromley пре 5 година
родитељ
комит
df88d58db8

+ 3 - 0
packages/ui-devkit/package.json

@@ -41,13 +41,16 @@
     "@angular/compiler-cli": "^9.0.6",
     "@vendure/admin-ui": "^0.10.0",
     "@vendure/common": "^0.10.0",
+    "chalk": "^3.0.0",
     "chokidar": "^3.0.2",
     "fs-extra": "^8.1.0",
+    "glob": "^7.1.6",
     "rxjs": "^6.5.4"
   },
   "devDependencies": {
     "@rollup/plugin-node-resolve": "^6.0.0",
     "@types/fs-extra": "^8.1.0",
+    "@types/glob": "^7.1.1",
     "@vendure/core": "^0.10.0",
     "rimraf": "^3.0.0",
     "rollup": "^1.27.9",

+ 10 - 262
packages/ui-devkit/src/compiler/compile.ts

@@ -1,23 +1,20 @@
 /* tslint:disable:no-console */
 import { AdminUiAppConfig, AdminUiAppDevModeConfig } from '@vendure/common/lib/shared-types';
-import { ChildProcess, execSync, spawn } from 'child_process';
+import { ChildProcess, spawn } from 'child_process';
 import { FSWatcher, watch as chokidarWatch } from 'chokidar';
-import { createHash } from 'crypto';
 import * as fs from 'fs-extra';
 import * as path from 'path';
 
+import { MODULES_OUTPUT_DIR } from './constants';
+import { setupScaffold } from './scaffold';
+import { AdminUiExtension, StaticAssetDefinition, UiExtensionCompilerOptions } from './types';
 import {
-    AdminUiExtension,
-    AdminUiExtensionLazyModule,
-    AdminUiExtensionSharedModule,
-    StaticAssetDefinition,
-    UiExtensionCompilerOptions,
-} from './types';
-
-const STATIC_ASSETS_OUTPUT_DIR = 'static-assets';
-const MODULES_OUTPUT_DIR = 'src/extensions';
-const EXTENSION_ROUTES_FILE = 'src/extension.routes.ts';
-const SHARED_EXTENSIONS_FILE = 'src/shared-extensions.module.ts';
+    copyStaticAsset,
+    copyUiDevkit,
+    getStaticAssetPath,
+    normalizeExtensions,
+    shouldUseYarn,
+} from './utils';
 
 /**
  * @description
@@ -157,252 +154,3 @@ function runWatchMode(
     process.on('SIGINT', close);
     return { sourcePath: outputPath, port, compile };
 }
-
-async function setupScaffold(outputPath: string, extensions: AdminUiExtension[]) {
-    deleteExistingExtensionModules(outputPath);
-    copySourceIfNotExists(outputPath);
-    await copyExtensionModules(outputPath, normalizeExtensions(extensions));
-    copyUiDevkit(outputPath);
-    try {
-        await checkIfNgccWasRun();
-    } catch (e) {
-        const cmd = shouldUseYarn() ? 'yarn ngcc' : 'npx ngcc';
-        console.log(
-            `An error occurred when running ngcc. Try removing node_modules, re-installing, and then manually running "${cmd}" in the project root.`,
-        );
-    }
-}
-
-/**
- * Deletes the contents of the /modules directory, which contains the plugin
- * extension modules copied over during the last compilation.
- */
-export function deleteExistingExtensionModules(outputPath: string) {
-    fs.removeSync(path.join(outputPath, MODULES_OUTPUT_DIR));
-}
-
-/**
- * Ensures each extension has an ID. If not defined by the user, a deterministic ID is generated
- * from a hash of the extension config.
- */
-function normalizeExtensions(extensions?: AdminUiExtension[]): Array<Required<AdminUiExtension>> {
-    return (extensions || []).map(e => {
-        if (e.id) {
-            return e as Required<AdminUiExtension>;
-        }
-        const hash = createHash('sha256');
-        hash.update(JSON.stringify(e));
-        const id = hash.digest('hex');
-        return { staticAssets: [], ...e, id };
-    });
-}
-
-/**
- * Copies all files from the extensionPaths of the configured extensions into the
- * admin-ui source tree.
- */
-export async function copyExtensionModules(
-    outputPath: string,
-    extensions: Array<Required<AdminUiExtension>>,
-) {
-    const extensionRoutesSource = generateLazyExtensionRoutes(extensions);
-    fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8');
-    const sharedExtensionModulesSource = generateSharedExtensionModule(extensions);
-    fs.writeFileSync(path.join(outputPath, SHARED_EXTENSIONS_FILE), sharedExtensionModulesSource, 'utf8');
-
-    for (const extension of extensions) {
-        const dirName = path.basename(path.dirname(extension.extensionPath));
-        const dest = path.join(outputPath, MODULES_OUTPUT_DIR, extension.id);
-        fs.copySync(extension.extensionPath, dest);
-        if (Array.isArray(extension.staticAssets)) {
-            for (const asset of extension.staticAssets) {
-                await copyStaticAsset(outputPath, asset);
-            }
-        }
-    }
-}
-
-function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension>>): string {
-    const routes: string[] = [];
-    for (const extension of extensions as Array<Required<AdminUiExtension>>) {
-        for (const module of extension.ngModules) {
-            if (module.type === 'lazy') {
-                routes.push(`  {
-    path: 'extensions/${module.route}',
-    loadChildren: () => import('${getModuleFilePath(extension.id, module)}').then(m => m.${
-                    module.ngModuleName
-                }),
-  }`);
-            }
-        }
-    }
-    return `export const extensionRoutes = [${routes.join(',\n')}];\n`;
-}
-
-function generateSharedExtensionModule(extensions: Array<Required<AdminUiExtension>>) {
-    return `import { NgModule } from '@angular/core';
-import { CommonModule } from '@angular/common';
-${extensions
-    .map(e =>
-        e.ngModules
-            .filter(m => m.type === 'shared')
-            .map(m => `import { ${m.ngModuleName} } from '${getModuleFilePath(e.id, m)}';\n`),
-    )
-    .join('')}
-
-@NgModule({
-    imports: [CommonModule, ${extensions
-        .map(e =>
-            e.ngModules
-                .filter(m => m.type === 'shared')
-                .map(m => m.ngModuleName)
-                .join(', '),
-        )
-        .join(', ')}],
-})
-export class SharedExtensionsModule {}
-`;
-}
-
-function getModuleFilePath(
-    id: string,
-    module: AdminUiExtensionLazyModule | AdminUiExtensionSharedModule,
-): string {
-    return `./extensions/${id}/${path.basename(module.ngModuleFileName, '.ts')}`;
-}
-
-/**
- * Copies over any files defined by the extensions' `staticAssets` array to the shared
- * static assets directory. When the app is built by the ng cli, this assets directory is
- * the copied over to the final static assets location (i.e. http://domain/admin/assets/)
- */
-export async function copyStaticAsset(outputPath: string, staticAssetDef: StaticAssetDefinition) {
-    const staticAssetPath = getStaticAssetPath(staticAssetDef);
-    const stats = fs.statSync(staticAssetPath);
-    let assetOutputPath: string;
-    if (stats.isDirectory()) {
-        const assetDirname = path.basename(staticAssetPath);
-        assetOutputPath = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR, assetDirname);
-    } else {
-        assetOutputPath = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR);
-    }
-    fs.copySync(staticAssetPath, assetOutputPath);
-    if (typeof staticAssetDef !== 'string') {
-        // The asset is being renamed
-        const newName = path.join(path.dirname(assetOutputPath), staticAssetDef.rename);
-        try {
-            // We use copy, remove rather than rename due to problems with the
-            // EPERM error in Windows.
-            await fs.copy(assetOutputPath, newName);
-            await fs.remove(assetOutputPath);
-        } catch (e) {
-            console.log(e);
-        }
-    }
-}
-
-/**
- * Copy the @vendure/ui-devkit files to the static assets dir.
- */
-export function copyUiDevkit(outputPath: string) {
-    const devkitDir = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR, 'devkit');
-    fs.ensureDirSync(devkitDir);
-    fs.copySync(require.resolve('@vendure/ui-devkit'), path.join(devkitDir, 'ui-devkit.js'));
-}
-
-/**
- * Copy the Admin UI sources & static assets to the outputPath if it does not already
- * exists there.
- */
-function copySourceIfNotExists(outputPath: string) {
-    const angularJsonFile = path.join(outputPath, 'angular.json');
-    const indexFile = path.join(outputPath, '/src/index.html');
-    if (fs.existsSync(angularJsonFile) && fs.existsSync(indexFile)) {
-        return;
-    }
-    const scaffoldDir = path.join(__dirname, '../scaffold');
-    const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static');
-
-    if (!fs.existsSync(scaffoldDir)) {
-        throw new Error(`Could not find the admin ui scaffold files at ${scaffoldDir}`);
-    }
-    if (!fs.existsSync(adminUiSrc)) {
-        throw new Error(`Could not find the @vendure/admin-ui sources. Looked in ${adminUiSrc}`);
-    }
-
-    // copy scaffold
-    fs.removeSync(outputPath);
-    fs.ensureDirSync(outputPath);
-    fs.copySync(scaffoldDir, outputPath);
-
-    // copy source files from admin-ui package
-    const outputSrc = path.join(outputPath, 'src');
-    fs.ensureDirSync(outputSrc);
-    fs.copySync(adminUiSrc, outputSrc);
-}
-
-/**
- * Attempts to find out it the ngcc compiler has been run on the Angular packages, and if not,
- * attemps to run it. This is done this way because attempting to run ngcc from a sub-directory
- * where the angular libs are in a higher-level node_modules folder currently results in the error
- * NG6002, see https://github.com/angular/angular/issues/35747.
- *
- * However, when ngcc is run from the root, it works.
- */
-async function checkIfNgccWasRun(): Promise<void> {
-    const coreUmdFile = require.resolve('@vendure/admin-ui/core');
-    if (!coreUmdFile) {
-        console.log(`Could not resolve the "@vendure/admin-ui/core" package!`);
-        return;
-    }
-    // ngcc creates a particular folder after it has been run once
-    const ivyDir = path.join(coreUmdFile, '../..', '__ivy_ngcc__');
-    if (fs.existsSync(ivyDir)) {
-        return;
-    }
-    // Looks like ngcc has not been run, so attempt to do so.
-    const rootDir = coreUmdFile.split('node_modules')[0];
-    return new Promise((resolve, reject) => {
-        console.log(
-            'Running the Angular Ivy compatibility compiler (ngcc) on Vendure Admin UI dependencies ' +
-                '(this is only needed on the first run)...',
-        );
-        const cmd = shouldUseYarn() ? 'yarn' : 'npx';
-        const ngccProcess = spawn(
-            cmd,
-            [
-                'ngcc',
-                '--properties es2015 browser module main',
-                '--first-only',
-                '--create-ivy-entry-points',
-                '-l=error',
-            ],
-            {
-                cwd: rootDir,
-                shell: true,
-                stdio: 'inherit',
-            },
-        );
-
-        ngccProcess.on('close', code => {
-            if (code !== 0) {
-                reject(code);
-            } else {
-                resolve();
-            }
-        });
-    });
-}
-
-export function shouldUseYarn(): boolean {
-    try {
-        execSync('yarnpkg --version', { stdio: 'ignore' });
-        return true;
-    } catch (e) {
-        return false;
-    }
-}
-
-function getStaticAssetPath(staticAssetDef: StaticAssetDefinition): string {
-    return typeof staticAssetDef === 'string' ? staticAssetDef : staticAssetDef.path;
-}

+ 4 - 0
packages/ui-devkit/src/compiler/constants.ts

@@ -0,0 +1,4 @@
+export const STATIC_ASSETS_OUTPUT_DIR = 'static-assets';
+export const EXTENSION_ROUTES_FILE = 'src/extension.routes.ts';
+export const SHARED_EXTENSIONS_FILE = 'src/shared-extensions.module.ts';
+export const MODULES_OUTPUT_DIR = 'src/extensions';

+ 282 - 0
packages/ui-devkit/src/compiler/scaffold.ts

@@ -0,0 +1,282 @@
+/* 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 {
+    AdminUiExtension,
+    AdminUiExtensionLazyModule,
+    AdminUiExtensionSharedModule,
+    Translations,
+} from './types';
+import { copyStaticAsset, copyUiDevkit, logger, normalizeExtensions, shouldUseYarn } from './utils';
+
+export async function setupScaffold(outputPath: string, extensions: AdminUiExtension[]) {
+    deleteExistingExtensionModules(outputPath);
+    copySourceIfNotExists(outputPath);
+    const normalizedExtensions = normalizeExtensions(extensions);
+    await copyExtensionModules(outputPath, normalizedExtensions);
+    await mergeExtensionTranslations(outputPath, normalizedExtensions);
+    copyUiDevkit(outputPath);
+    try {
+        await checkIfNgccWasRun();
+    } catch (e) {
+        const cmd = shouldUseYarn() ? 'yarn ngcc' : 'npx ngcc';
+        logger.log(
+            `An error occurred when running ngcc. Try removing node_modules, re-installing, and then manually running "${cmd}" in the project root.`,
+        );
+    }
+}
+
+/**
+ * Deletes the contents of the /modules directory, which contains the plugin
+ * extension modules copied over during the last compilation.
+ */
+function deleteExistingExtensionModules(outputPath: string) {
+    fs.removeSync(path.join(outputPath, MODULES_OUTPUT_DIR));
+}
+
+/**
+ * Copies all files from the extensionPaths of the configured extensions into the
+ * admin-ui source tree.
+ */
+async function copyExtensionModules(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
+    const extensionRoutesSource = generateLazyExtensionRoutes(extensions);
+    fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8');
+    const sharedExtensionModulesSource = generateSharedExtensionModule(extensions);
+    fs.writeFileSync(path.join(outputPath, SHARED_EXTENSIONS_FILE), sharedExtensionModulesSource, 'utf8');
+
+    for (const extension of extensions) {
+        const dirName = path.basename(path.dirname(extension.extensionPath));
+        const dest = path.join(outputPath, MODULES_OUTPUT_DIR, extension.id);
+        fs.copySync(extension.extensionPath, dest);
+        if (Array.isArray(extension.staticAssets)) {
+            for (const asset of extension.staticAssets) {
+                await copyStaticAsset(outputPath, asset);
+            }
+        }
+    }
+}
+
+function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension>>): string {
+    const routes: string[] = [];
+    for (const extension of extensions as Array<Required<AdminUiExtension>>) {
+        for (const module of extension.ngModules) {
+            if (module.type === 'lazy') {
+                routes.push(`  {
+    path: 'extensions/${module.route}',
+    loadChildren: () => import('${getModuleFilePath(extension.id, module)}').then(m => m.${
+                    module.ngModuleName
+                }),
+  }`);
+            }
+        }
+    }
+    return `export const extensionRoutes = [${routes.join(',\n')}];\n`;
+}
+
+function generateSharedExtensionModule(extensions: Array<Required<AdminUiExtension>>) {
+    return `import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+${extensions
+    .map(e =>
+        e.ngModules
+            .filter(m => m.type === 'shared')
+            .map(m => `import { ${m.ngModuleName} } from '${getModuleFilePath(e.id, m)}';\n`),
+    )
+    .join('')}
+
+@NgModule({
+    imports: [CommonModule, ${extensions
+        .map(e =>
+            e.ngModules
+                .filter(m => m.type === 'shared')
+                .map(m => m.ngModuleName)
+                .join(', '),
+        )
+        .join(', ')}],
+})
+export class SharedExtensionsModule {}
+`;
+}
+
+function getModuleFilePath(
+    id: string,
+    module: AdminUiExtensionLazyModule | AdminUiExtensionSharedModule,
+): string {
+    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.
+ */
+function copySourceIfNotExists(outputPath: string) {
+    const angularJsonFile = path.join(outputPath, 'angular.json');
+    const indexFile = path.join(outputPath, '/src/index.html');
+    if (fs.existsSync(angularJsonFile) && fs.existsSync(indexFile)) {
+        return;
+    }
+    const scaffoldDir = path.join(__dirname, '../scaffold');
+    const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static');
+
+    if (!fs.existsSync(scaffoldDir)) {
+        throw new Error(`Could not find the admin ui scaffold files at ${scaffoldDir}`);
+    }
+    if (!fs.existsSync(adminUiSrc)) {
+        throw new Error(`Could not find the @vendure/admin-ui sources. Looked in ${adminUiSrc}`);
+    }
+
+    // copy scaffold
+    fs.removeSync(outputPath);
+    fs.ensureDirSync(outputPath);
+    fs.copySync(scaffoldDir, outputPath);
+
+    // copy source files from admin-ui package
+    const outputSrc = path.join(outputPath, 'src');
+    fs.ensureDirSync(outputSrc);
+    fs.copySync(adminUiSrc, outputSrc);
+}
+
+/**
+ * Attempts to find out it the ngcc compiler has been run on the Angular packages, and if not,
+ * attemps to run it. This is done this way because attempting to run ngcc from a sub-directory
+ * where the angular libs are in a higher-level node_modules folder currently results in the error
+ * NG6002, see https://github.com/angular/angular/issues/35747.
+ *
+ * However, when ngcc is run from the root, it works.
+ */
+async function checkIfNgccWasRun(): Promise<void> {
+    const coreUmdFile = require.resolve('@vendure/admin-ui/core');
+    if (!coreUmdFile) {
+        logger.error(`Could not resolve the "@vendure/admin-ui/core" package!`);
+        return;
+    }
+    // ngcc creates a particular folder after it has been run once
+    const ivyDir = path.join(coreUmdFile, '../..', '__ivy_ngcc__');
+    if (fs.existsSync(ivyDir)) {
+        return;
+    }
+    // Looks like ngcc has not been run, so attempt to do so.
+    const rootDir = coreUmdFile.split('node_modules')[0];
+    return new Promise((resolve, reject) => {
+        logger.log(
+            'Running the Angular Ivy compatibility compiler (ngcc) on Vendure Admin UI dependencies ' +
+                '(this is only needed on the first run)...',
+        );
+        const cmd = shouldUseYarn() ? 'yarn' : 'npx';
+        const ngccProcess = spawn(
+            cmd,
+            [
+                'ngcc',
+                '--properties es2015 browser module main',
+                '--first-only',
+                '--create-ivy-entry-points',
+                '-l=error',
+            ],
+            {
+                cwd: rootDir,
+                shell: true,
+                stdio: 'inherit',
+            },
+        );
+
+        ngccProcess.on('close', code => {
+            if (code !== 0) {
+                reject(code);
+            } else {
+                resolve();
+            }
+        });
+    });
+}

+ 24 - 0
packages/ui-devkit/src/compiler/types.ts

@@ -1,3 +1,5 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+
 /**
  * @description
  * Defines extensions to the Admin UI application by specifying additional
@@ -37,6 +39,22 @@ 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 };
 }
 
 /**
@@ -146,3 +164,9 @@ export interface UiExtensionCompilerOptions {
      */
     watchPort?: number;
 }
+
+export type Translations = {
+    [section: string]: {
+        [token: string]: string;
+    };
+};

+ 90 - 0
packages/ui-devkit/src/compiler/utils.ts

@@ -0,0 +1,90 @@
+/* tslint:disable:no-console */
+import * as chalk from 'chalk';
+import { execSync } from 'child_process';
+import { createHash } from 'crypto';
+import * as fs from 'fs-extra';
+import * as path from 'path';
+
+import { STATIC_ASSETS_OUTPUT_DIR } from './constants';
+import { AdminUiExtension, StaticAssetDefinition } from './types';
+
+export const logger = {
+    log: (message: string) => console.log(chalk.green(message)),
+    error: (message: string) => console.log(chalk.red(message)),
+};
+
+/**
+ * Checks for the global yarn binary and returns true if found.
+ */
+export function shouldUseYarn(): boolean {
+    try {
+        execSync('yarnpkg --version', { stdio: 'ignore' });
+        return true;
+    } catch (e) {
+        return false;
+    }
+}
+
+/**
+ * Returns the string path of a static asset
+ */
+export function getStaticAssetPath(staticAssetDef: StaticAssetDefinition): string {
+    return typeof staticAssetDef === 'string' ? staticAssetDef : staticAssetDef.path;
+}
+
+/**
+ * Copy the @vendure/ui-devkit files to the static assets dir.
+ */
+export function copyUiDevkit(outputPath: string) {
+    const devkitDir = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR, 'devkit');
+    fs.ensureDirSync(devkitDir);
+    fs.copySync(require.resolve('@vendure/ui-devkit'), path.join(devkitDir, 'ui-devkit.js'));
+}
+
+/**
+ * Copies over any files defined by the extensions' `staticAssets` array to the shared
+ * static assets directory. When the app is built by the ng cli, this assets directory is
+ * the copied over to the final static assets location (i.e. http://domain/admin/assets/)
+ */
+export async function copyStaticAsset(outputPath: string, staticAssetDef: StaticAssetDefinition) {
+    const staticAssetPath = getStaticAssetPath(staticAssetDef);
+    const stats = fs.statSync(staticAssetPath);
+    let assetOutputPath: string;
+    if (stats.isDirectory()) {
+        const assetDirname = path.basename(staticAssetPath);
+        assetOutputPath = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR, assetDirname);
+    } else {
+        assetOutputPath = path.join(outputPath, STATIC_ASSETS_OUTPUT_DIR);
+    }
+    fs.copySync(staticAssetPath, assetOutputPath);
+    if (typeof staticAssetDef !== 'string') {
+        // The asset is being renamed
+        const newName = path.join(path.dirname(assetOutputPath), staticAssetDef.rename);
+        try {
+            // We use copy, remove rather than rename due to problems with the
+            // EPERM error in Windows.
+            await fs.copy(assetOutputPath, newName);
+            await fs.remove(assetOutputPath);
+        } catch (e) {
+            logger.log(e);
+        }
+    }
+}
+
+/**
+ * Ensures each extension has an ID and a value for the optional properties.
+ * If not defined by the user, a deterministic ID is generated
+ * from a hash of the extension config.
+ */
+export function normalizeExtensions(extensions?: AdminUiExtension[]): Array<Required<AdminUiExtension>> {
+    return (extensions || []).map(e => {
+        let id = e.id;
+        if (!id) {
+            const hash = createHash('sha256');
+            hash.update(JSON.stringify(e));
+            id = hash.digest('hex');
+        }
+
+        return { staticAssets: [], translations: {}, ...e, id };
+    });
+}

+ 1 - 0
packages/ui-devkit/tsconfig.compiler.json

@@ -7,6 +7,7 @@
     "module": "commonjs",
     "declaration": true,
     "moduleResolution": "node",
+    "esModuleInterop": true,
     "strict": true,
     "target": "esnext",
     "skipLibCheck": true,

+ 12 - 0
yarn.lock

@@ -8427,6 +8427,18 @@ glob@7.1.5:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^7.1.6:
+  version "7.1.6"
+  resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 global-dirs@^0.1.1:
   version "0.1.1"
   resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"