Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { colors } from '../../utils/color';
import { copyAssets } from '../../utils/copy-assets';
import { assertIsError } from '../../utils/error';
import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining';
import { I18nOptions } from '../../utils/i18n-options';
import { I18nOptions } from '../../utils/i18n-webpack';
import { FileInfo } from '../../utils/index-file/augment-index-html';
import {
IndexHtmlGenerator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { ExecutionTransformer } from '../../transforms';
import { normalizeOptimization } from '../../utils';
import { colors } from '../../utils/color';
import { I18nOptions, loadTranslations } from '../../utils/i18n-options';
import { I18nOptions, loadTranslations } from '../../utils/i18n-webpack';
import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator';
import { createTranslationLoader } from '../../utils/load-translations';
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { colors } from '../../utils/color';
import { copyAssets } from '../../utils/copy-assets';
import { assertIsError } from '../../utils/error';
import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining';
import { I18nOptions } from '../../utils/i18n-options';
import { I18nOptions } from '../../utils/i18n-webpack';
import { ensureOutputPaths } from '../../utils/output-paths';
import { purgeStaleBuildCache } from '../../utils/purge-cache';
import { Spinner } from '../../utils/spinner';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Piscina from 'piscina';
import { InlineOptions } from './bundle-inline-options';
import { maxWorkers } from './environment-options';
import { I18nOptions } from './i18n-options';
import { I18nOptions } from './i18n-webpack';

const workerFile = require.resolve('./process-bundle');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { BundleActionExecutor } from './action-executor';
import { InlineOptions } from './bundle-inline-options';
import { copyAssets } from './copy-assets';
import { assertIsError } from './error';
import { I18nOptions } from './i18n-options';
import { I18nOptions } from './i18n-webpack';
import { Spinner } from './spinner';

function emittedFilesToInlineOptions(
Expand Down
137 changes: 5 additions & 132 deletions packages/angular_devkit/build_angular/src/utils/i18n-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import { BuilderContext } from '@angular-devkit/architect';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { Schema as BrowserBuilderSchema, I18NTranslation } from '../builders/browser/schema';
import { Schema as ServerBuilderSchema } from '../builders/server/schema';
import { readTsconfig } from '../utils/read-tsconfig';
import { TranslationLoader, createTranslationLoader } from './load-translations';

/**
* The base module location used to search for locale specific data.
*/
const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global';
import type { TranslationLoader } from './load-translations';

export interface LocaleDescription {
files: {
Expand Down Expand Up @@ -168,129 +156,14 @@ export function createI18nOptions(
return i18n;
}

export async function configureI18nBuild<T extends BrowserBuilderSchema | ServerBuilderSchema>(
context: BuilderContext,
options: T,
): Promise<{
buildOptions: T;
i18n: I18nOptions;
}> {
if (!context.target) {
throw new Error('The builder requires a target.');
}

const buildOptions = { ...options };
const tsConfig = await readTsconfig(buildOptions.tsConfig, context.workspaceRoot);
const metadata = await context.getProjectMetadata(context.target);
const i18n = createI18nOptions(metadata, buildOptions.localize);

// No additional processing needed if no inlining requested and no source locale defined.
if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) {
return { buildOptions, i18n };
}

const projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || '');
// The trailing slash is required to signal that the path is a directory and not a file.
const projectRequire = createRequire(projectRoot + '/');
const localeResolver = (locale: string) =>
projectRequire.resolve(path.join(LOCALE_DATA_BASE_MODULE, locale));

// Load locale data and translations (if present)
let loader;
const usedFormats = new Set<string>();
for (const [locale, desc] of Object.entries(i18n.locales)) {
if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) {
continue;
}

let localeDataPath = findLocaleDataPath(locale, localeResolver);
if (!localeDataPath) {
const [first] = locale.split('-');
if (first) {
localeDataPath = findLocaleDataPath(first.toLowerCase(), localeResolver);
if (localeDataPath) {
context.logger.warn(
`Locale data for '${locale}' cannot be found. Using locale data for '${first}'.`,
);
}
}
}
if (!localeDataPath) {
context.logger.warn(
`Locale data for '${locale}' cannot be found. No locale data will be included for this locale.`,
);
} else {
desc.dataPath = localeDataPath;
}

if (!desc.files.length) {
continue;
}

loader ??= await createTranslationLoader();

loadTranslations(
locale,
desc,
context.workspaceRoot,
loader,
{
warn(message) {
context.logger.warn(message);
},
error(message) {
throw new Error(message);
},
},
usedFormats,
buildOptions.i18nDuplicateTranslation,
);

if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
// This limitation is only for legacy message id support (defaults to true as of 9.0)
throw new Error(
'Localization currently only supports using one type of translation file format for the entire application.',
);
}
}

// If inlining store the output in a temporary location to facilitate post-processing
if (i18n.shouldInline) {
// TODO: we should likely save these in the .angular directory in the next major version.
// We'd need to do a migration to add the temp directory to gitignore.
const tempPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-i18n-'));
buildOptions.outputPath = tempPath;

process.on('exit', () => {
try {
fs.rmSync(tempPath, { force: true, recursive: true, maxRetries: 3 });
} catch {}
});
}

return { buildOptions, i18n };
}

function findLocaleDataPath(locale: string, resolver: (locale: string) => string): string | null {
// Remove private use subtags
const scrubbedLocale = locale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, '');

try {
return resolver(scrubbedLocale);
} catch {
// fallback to known existing en-US locale data as of 14.0
return scrubbedLocale === 'en-US' ? findLocaleDataPath('en', resolver) : null;
}
}

export function loadTranslations(
locale: string,
desc: LocaleDescription,
workspaceRoot: string,
loader: TranslationLoader,
logger: { warn: (message: string) => void; error: (message: string) => void },
usedFormats?: Set<string>,
duplicateTranslation?: I18NTranslation,
duplicateTranslation?: 'ignore' | 'error' | 'warning',
) {
let translations: Record<string, unknown> | undefined = undefined;
for (const file of desc.files) {
Expand Down Expand Up @@ -320,12 +193,12 @@ export function loadTranslations(
if (translations[id] !== undefined) {
const duplicateTranslationMessage = `[${file.path}]: Duplicate translations for message '${id}' when merging.`;
switch (duplicateTranslation) {
case I18NTranslation.Ignore:
case 'ignore':
break;
case I18NTranslation.Error:
case 'error':
logger.error(`ERROR ${duplicateTranslationMessage}`);
break;
case I18NTranslation.Warning:
case 'warning':
default:
logger.warn(`WARNING ${duplicateTranslationMessage}`);
break;
Expand Down
141 changes: 141 additions & 0 deletions packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { BuilderContext } from '@angular-devkit/architect';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { Schema as BrowserBuilderSchema } from '../builders/browser/schema';
import { Schema as ServerBuilderSchema } from '../builders/server/schema';
import { readTsconfig } from '../utils/read-tsconfig';
import { I18nOptions, createI18nOptions, loadTranslations } from './i18n-options';
import { createTranslationLoader } from './load-translations';

/**
* The base module location used to search for locale specific data.
*/
const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global';

// Re-export for use within Webpack related builders
export { I18nOptions, loadTranslations };

export async function configureI18nBuild<T extends BrowserBuilderSchema | ServerBuilderSchema>(
context: BuilderContext,
options: T,
): Promise<{
buildOptions: T;
i18n: I18nOptions;
}> {
if (!context.target) {
throw new Error('The builder requires a target.');
}

const buildOptions = { ...options };
const tsConfig = await readTsconfig(buildOptions.tsConfig, context.workspaceRoot);
const metadata = await context.getProjectMetadata(context.target);
const i18n = createI18nOptions(metadata, buildOptions.localize);

// No additional processing needed if no inlining requested and no source locale defined.
if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) {
return { buildOptions, i18n };
}

const projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || '');
// The trailing slash is required to signal that the path is a directory and not a file.
const projectRequire = createRequire(projectRoot + '/');
const localeResolver = (locale: string) =>
projectRequire.resolve(path.join(LOCALE_DATA_BASE_MODULE, locale));

// Load locale data and translations (if present)
let loader;
const usedFormats = new Set<string>();
for (const [locale, desc] of Object.entries(i18n.locales)) {
if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) {
continue;
}

let localeDataPath = findLocaleDataPath(locale, localeResolver);
if (!localeDataPath) {
const [first] = locale.split('-');
if (first) {
localeDataPath = findLocaleDataPath(first.toLowerCase(), localeResolver);
if (localeDataPath) {
context.logger.warn(
`Locale data for '${locale}' cannot be found. Using locale data for '${first}'.`,
);
}
}
}
if (!localeDataPath) {
context.logger.warn(
`Locale data for '${locale}' cannot be found. No locale data will be included for this locale.`,
);
} else {
desc.dataPath = localeDataPath;
}

if (!desc.files.length) {
continue;
}

loader ??= await createTranslationLoader();

loadTranslations(
locale,
desc,
context.workspaceRoot,
loader,
{
warn(message) {
context.logger.warn(message);
},
error(message) {
throw new Error(message);
},
},
usedFormats,
buildOptions.i18nDuplicateTranslation,
);

if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
// This limitation is only for legacy message id support (defaults to true as of 9.0)
throw new Error(
'Localization currently only supports using one type of translation file format for the entire application.',
);
}
}

// If inlining store the output in a temporary location to facilitate post-processing
if (i18n.shouldInline) {
// TODO: we should likely save these in the .angular directory in the next major version.
// We'd need to do a migration to add the temp directory to gitignore.
const tempPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-i18n-'));
buildOptions.outputPath = tempPath;

process.on('exit', () => {
try {
fs.rmSync(tempPath, { force: true, recursive: true, maxRetries: 3 });
} catch {}
});
}

return { buildOptions, i18n };
}

function findLocaleDataPath(locale: string, resolver: (locale: string) => string): string | null {
// Remove private use subtags
const scrubbedLocale = locale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, '');

try {
return resolver(scrubbedLocale);
} catch {
// fallback to known existing en-US locale data as of 14.0
return scrubbedLocale === 'en-US' ? findLocaleDataPath('en', resolver) : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { I18nOptions } from './i18n-options';
import { I18nOptions } from './i18n-webpack';

export function ensureOutputPaths(baseOutputPath: string, i18n: I18nOptions): Map<string, string> {
const outputPaths: [string, string][] = i18n.shouldInline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { workerData } from 'worker_threads';
import { InlineOptions } from './bundle-inline-options';
import { allowMinify, shouldBeautify } from './environment-options';
import { assertIsError } from './error';
import { I18nOptions } from './i18n-options';
import { I18nOptions } from './i18n-webpack';
import { loadEsmModule } from './load-esm';

// Extract Sourcemap input type from the remapping function since it is not currently exported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { NormalizedBrowserBuilderSchema, defaultProgress, normalizeBrowserSchema } from '../utils';
import { WebpackConfigOptions } from '../utils/build-options';
import { readTsconfig } from '../utils/read-tsconfig';
import { I18nOptions, configureI18nBuild } from './i18n-options';
import { I18nOptions, configureI18nBuild } from './i18n-webpack';

export type BrowserWebpackConfigOptions = WebpackConfigOptions<NormalizedBrowserBuilderSchema>;

Expand Down