Skip to content

Commit 2b41cd1

Browse files
clydinvikerman
authored andcommitted
feat(@angular-devkit/build-angular): initial support for i18n translation inlining
1 parent 4ede5b6 commit 2b41cd1

File tree

7 files changed

+448
-64
lines changed

7 files changed

+448
-64
lines changed

packages/angular_devkit/build_angular/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@
9898
},
9999
"peerDependencies": {
100100
"@angular/compiler-cli": ">=9.0.0-beta < 10",
101+
"@angular/localize": "^9.0.0-next.10",
101102
"typescript": ">=3.6 < 3.7"
103+
},
104+
"peerDependenciesMeta": {
105+
"@angular/localize": {
106+
"optional": true
107+
}
102108
}
103109
}

packages/angular_devkit/build_angular/src/browser/index.ts

Lines changed: 207 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,14 @@ import {
1111
WebpackLoggingCallback,
1212
runWebpack,
1313
} from '@angular-devkit/build-webpack';
14-
import {
15-
join,
16-
json,
17-
logging,
18-
normalize,
19-
tags,
20-
virtualFs,
21-
} from '@angular-devkit/core';
14+
import { join, json, logging, normalize, tags, virtualFs } from '@angular-devkit/core';
2215
import { NodeJsSyncHost } from '@angular-devkit/core/node';
2316
import * as findCacheDirectory from 'find-cache-dir';
2417
import * as fs from 'fs';
18+
import * as os from 'os';
2519
import * as path from 'path';
2620
import { Observable, from, of } from 'rxjs';
27-
import { catchError, concatMap, map, switchMap } from 'rxjs/operators';
21+
import { concatMap, map, switchMap } from 'rxjs/operators';
2822
import { ScriptTarget } from 'typescript';
2923
import * as webpack from 'webpack';
3024
import { NgBuildAnalyticsPlugin } from '../../plugins/webpack/analytics';
@@ -62,6 +56,7 @@ import {
6256
} from '../utils';
6357
import { copyAssets } from '../utils/copy-assets';
6458
import { I18nOptions, createI18nOptions } from '../utils/i18n-options';
59+
import { createTranslationLoader } from '../utils/load-translations';
6560
import {
6661
ProcessBundleFile,
6762
ProcessBundleOptions,
@@ -167,17 +162,54 @@ async function initialize(
167162
projectSourceRoot?: string;
168163
i18n: I18nOptions;
169164
}> {
165+
if (!context.target) {
166+
throw new Error('The builder requires a target.');
167+
}
168+
169+
const metadata = await context.getProjectMetadata(context.target);
170+
const i18n = createI18nOptions(metadata, options.localize);
171+
172+
if (i18n.inlineLocales.size > 0) {
173+
// Load locales
174+
const loader = await createTranslationLoader();
175+
176+
const usedFormats = new Set<string>();
177+
for (const [locale, desc] of Object.entries(i18n.locales)) {
178+
if (i18n.inlineLocales.has(locale)) {
179+
const result = loader(desc.file);
180+
181+
usedFormats.add(result.format);
182+
if (usedFormats.size > 1) {
183+
// This limitation is technically only for legacy message id support
184+
throw new Error(
185+
'Localization currently only supports using one type of translation file format for the entire application.',
186+
);
187+
}
188+
189+
desc.format = result.format;
190+
desc.translation = result.translation;
191+
}
192+
}
193+
194+
// Legacy message id's require the format of the translations
195+
if (usedFormats.size > 0) {
196+
options.i18nFormat = [...usedFormats][0];
197+
}
198+
}
199+
200+
const originalOutputPath = options.outputPath;
201+
202+
// If inlining store the output in a temporary location to facilitate post-processing
203+
if (i18n.shouldInline) {
204+
options.outputPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-'));
205+
}
206+
170207
const { config, projectRoot, projectSourceRoot } = await buildBrowserWebpackConfigFromContext(
171208
options,
172209
context,
173210
host,
174211
);
175212

176-
// target is verified in the above call
177-
// tslint:disable-next-line: no-non-null-assertion
178-
const metadata = await context.getProjectMetadata(context.target!);
179-
const i18n = createI18nOptions(metadata);
180-
181213
let transformedConfig;
182214
if (webpackConfigurationTransform) {
183215
transformedConfig = await webpackConfigurationTransform(config);
@@ -186,7 +218,7 @@ async function initialize(
186218
if (options.deleteOutputPath) {
187219
await deleteOutputDir(
188220
normalize(context.workspaceRoot),
189-
normalize(options.outputPath),
221+
normalize(originalOutputPath),
190222
host,
191223
).toPromise();
192224
}
@@ -254,6 +286,10 @@ export function buildWebpackBrowser(
254286

255287
return { success };
256288
} else if (success) {
289+
if (!fs.existsSync(baseOutputPath)) {
290+
fs.mkdirSync(baseOutputPath, { recursive: true });
291+
}
292+
257293
let noModuleFiles: EmittedFiles[] | undefined;
258294
let moduleFiles: EmittedFiles[] | undefined;
259295
let files: EmittedFiles[] | undefined;
@@ -272,6 +308,10 @@ export function buildWebpackBrowser(
272308
moduleFiles = [];
273309
noModuleFiles = [];
274310

311+
if (!webpackStats) {
312+
throw new Error('Webpack stats build result is required.');
313+
}
314+
275315
// Common options for all bundle process actions
276316
const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false);
277317
const actionOptions: Partial<ProcessBundleOptions> = {
@@ -324,7 +364,8 @@ export function buildWebpackBrowser(
324364

325365
// Retrieve the content/map for the file
326366
// NOTE: Additional future optimizations will read directly from memory
327-
let filename = path.join(baseOutputPath, file.file);
367+
// tslint:disable-next-line: no-non-null-assertion
368+
let filename = path.join(webpackStats.outputPath!, file.file);
328369
const code = fs.readFileSync(filename, 'utf8');
329370
let map;
330371
if (actionOptions.sourceMaps) {
@@ -368,9 +409,6 @@ export function buildWebpackBrowser(
368409
noModuleFiles.push({ ...file, file: newFilename });
369410
}
370411

371-
// Execute the bundle processing actions
372-
context.logger.info('Generating ES5 bundles for differential loading...');
373-
374412
const processActions: typeof actions = [];
375413
let processRuntimeAction: ProcessBundleOptions | undefined;
376414
const processResults: ProcessBundleResult[] = [];
@@ -389,29 +427,118 @@ export function buildWebpackBrowser(
389427
options.subresourceIntegrity ? 'sha384' : undefined,
390428
);
391429

430+
// Execute the bundle processing actions
392431
try {
432+
context.logger.info('Generating ES5 bundles for differential loading...');
433+
393434
for await (const result of executor.processAll(processActions)) {
394435
processResults.push(result);
395436
}
437+
438+
// Runtime must be processed after all other files
439+
if (processRuntimeAction) {
440+
const runtimeOptions = {
441+
...processRuntimeAction,
442+
runtimeData: processResults,
443+
};
444+
processResults.push(
445+
await import('../utils/process-bundle').then(m => m.process(runtimeOptions)),
446+
);
447+
}
448+
449+
context.logger.info('ES5 bundle generation complete.');
396450
} finally {
397451
executor.stop();
398452
}
399453

400-
// Runtime must be processed after all other files
401-
if (processRuntimeAction) {
402-
const runtimeOptions = {
403-
...processRuntimeAction,
404-
runtimeData: processResults,
405-
};
406-
processResults.push(
407-
await import('../utils/process-bundle').then(m => m.process(runtimeOptions)),
454+
if (i18n.shouldInline) {
455+
context.logger.info('Generating localized bundles...');
456+
457+
const localize = await import('@angular/localize/src/tools/src/translate/main');
458+
const localizeDiag = await import('@angular/localize/src/tools/src/diagnostics');
459+
460+
const diagnostics = new localizeDiag.Diagnostics();
461+
const translationFilePaths = [];
462+
let copySourceLocale = false;
463+
for (const locale of i18n.inlineLocales) {
464+
if (locale === i18n.sourceLocale) {
465+
copySourceLocale = true;
466+
continue;
467+
}
468+
translationFilePaths.push(i18n.locales[locale].file);
469+
}
470+
471+
if (copySourceLocale) {
472+
await copyAssets(
473+
[
474+
{
475+
glob: '**/*',
476+
// tslint:disable-next-line: no-non-null-assertion
477+
input: webpackStats.outputPath!,
478+
output: i18n.sourceLocale,
479+
},
480+
],
481+
[baseOutputPath],
482+
'',
483+
);
484+
}
485+
486+
if (translationFilePaths.length > 0) {
487+
const sourceFilePaths = [];
488+
for (const result of processResults) {
489+
if (result.original) {
490+
sourceFilePaths.push(result.original.filename);
491+
}
492+
if (result.downlevel) {
493+
sourceFilePaths.push(result.downlevel.filename);
494+
}
495+
}
496+
try {
497+
localize.translateFiles({
498+
// tslint:disable-next-line: no-non-null-assertion
499+
sourceRootPath: webpackStats.outputPath!,
500+
sourceFilePaths,
501+
translationFilePaths,
502+
outputPathFn: (locale, relativePath) =>
503+
path.join(baseOutputPath, locale, relativePath),
504+
diagnostics,
505+
missingTranslation: options.i18nMissingTranslation || 'warning',
506+
});
507+
} catch (err) {
508+
context.logger.error('Localized bundle generation failed: ' + err.message);
509+
510+
return { success: false };
511+
} finally {
512+
try {
513+
// Remove temporary directory used for i18n processing
514+
// tslint:disable-next-line: no-non-null-assertion
515+
await host.delete(normalize(webpackStats.outputPath!)).toPromise();
516+
} catch {}
517+
}
518+
}
519+
520+
context.logger.info(
521+
`Localized bundle generation ${diagnostics.hasErrors ? 'failed' : 'complete'}.`,
408522
);
409-
}
410523

411-
context.logger.info('ES5 bundle generation complete.');
524+
for (const message of diagnostics.messages) {
525+
if (message.type === 'error') {
526+
context.logger.error(message.message);
527+
} else {
528+
context.logger.warn(message.message);
529+
}
530+
}
531+
532+
if (diagnostics.hasErrors) {
533+
return { success: false };
534+
}
535+
}
412536

413537
// Copy assets
414538
if (options.assets) {
539+
const outputPaths = i18n.shouldInline
540+
? [...i18n.inlineLocales].map(l => path.join(baseOutputPath, l))
541+
: [baseOutputPath];
415542
try {
416543
await copyAssets(
417544
normalizeAssetPatterns(
@@ -421,7 +548,7 @@ export function buildWebpackBrowser(
421548
normalize(projectRoot),
422549
projectSourceRoot === undefined ? undefined : normalize(projectSourceRoot),
423550
),
424-
[baseOutputPath],
551+
outputPaths,
425552
context.workspaceRoot,
426553
);
427554
} catch (err) {
@@ -503,33 +630,29 @@ export function buildWebpackBrowser(
503630
}
504631

505632
if (options.index) {
506-
return writeIndexHtml({
507-
host,
508-
outputPath: join(normalize(baseOutputPath), getIndexOutputFile(options)),
509-
indexPath: join(root, getIndexInputFile(options)),
510-
files,
511-
noModuleFiles,
512-
moduleFiles,
513-
baseHref: options.baseHref,
514-
deployUrl: options.deployUrl,
515-
sri: options.subresourceIntegrity,
516-
scripts: options.scripts,
517-
styles: options.styles,
518-
postTransform: transforms.indexHtml,
519-
crossOrigin: options.crossOrigin,
520-
lang: options.i18nLocale,
521-
})
522-
.pipe(
523-
map(() => ({ success: true })),
524-
catchError(error => of({ success: false, error: mapErrorToMessage(error) })),
525-
)
526-
.toPromise();
527-
} else {
528-
return { success };
633+
const outputPaths = i18n.shouldInline
634+
? [...i18n.inlineLocales].map(l => path.join(baseOutputPath, l))
635+
: [baseOutputPath];
636+
637+
for (const outputPath of outputPaths) {
638+
try {
639+
await generateIndex(
640+
outputPath,
641+
options,
642+
root,
643+
files,
644+
noModuleFiles,
645+
moduleFiles,
646+
transforms.indexHtml,
647+
);
648+
} catch (err) {
649+
return { success: false, error: mapErrorToMessage(err) };
650+
}
651+
}
529652
}
530-
} else {
531-
return { success };
532653
}
654+
655+
return { success };
533656
}),
534657
concatMap(buildEvent => {
535658
if (buildEvent.success && !options.watch && options.serviceWorker) {
@@ -563,6 +686,35 @@ export function buildWebpackBrowser(
563686
);
564687
}
565688

689+
function generateIndex(
690+
baseOutputPath: string,
691+
options: BrowserBuilderSchema,
692+
root: string,
693+
files: EmittedFiles[] | undefined,
694+
noModuleFiles: EmittedFiles[] | undefined,
695+
moduleFiles: EmittedFiles[] | undefined,
696+
transformer?: IndexHtmlTransform,
697+
): Promise<void> {
698+
const host = new NodeJsSyncHost();
699+
700+
return writeIndexHtml({
701+
host,
702+
outputPath: join(normalize(baseOutputPath), getIndexOutputFile(options)),
703+
indexPath: join(normalize(root), getIndexInputFile(options)),
704+
files,
705+
noModuleFiles,
706+
moduleFiles,
707+
baseHref: options.baseHref,
708+
deployUrl: options.deployUrl,
709+
sri: options.subresourceIntegrity,
710+
scripts: options.scripts,
711+
styles: options.styles,
712+
postTransform: transformer,
713+
crossOrigin: options.crossOrigin,
714+
lang: options.i18nLocale,
715+
}).toPromise();
716+
}
717+
566718
function mapErrorToMessage(error: unknown): string | undefined {
567719
if (error instanceof Error) {
568720
return error.message;

0 commit comments

Comments
 (0)