Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): move i18n extraction for applica…
Browse files Browse the repository at this point in the history
…tion builder to new build system package

With the `application` builder already within the new `@angular/build` package,
the `extract-i18n` builder with application builder support is now also contained within this package.
Only the application builder aspects of `extract-i18n` have been moved.
The compatibility builder `browser-esbuild` is not supported with `@angular/build:extract-i18n`.
The existing `extract-i18n` builder found within `@angular-devkit/build-angular` should continue to be used for both the
Webpack-based `browser` builder and the esbuild-based compatibility `browser-esbuild`
builder. To maintain backwards compatibility, the existing `@angular-devkit/build-angular:extract-i18n`
builder continues to support builders it has previously.

No change to existing applications is required.
  • Loading branch information
clydin committed Apr 24, 2024
1 parent 7cedcc8 commit 0b03829
Show file tree
Hide file tree
Showing 11 changed files with 550 additions and 0 deletions.
12 changes: 12 additions & 0 deletions goldens/public-api/angular/build/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ export function executeDevServerBuilder(options: DevServerBuilderOptions, contex
indexHtmlTransformer?: IndexHtmlTransform;
}): AsyncIterable<DevServerBuilderOutput>;

// @public
export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): Promise<BuilderOutput>;

// @public
export interface ExtractI18nBuilderOptions {
buildTarget: string;
format?: Format;
outFile?: string;
outputPath?: string;
progress?: boolean;
}

// (No @packageDocumentation comment for this package)

```
6 changes: 6 additions & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ ts_json_schema(
src = "src/builders/dev-server/schema.json",
)

ts_json_schema(
name = "extract_i18n_schema",
src = "src/builders/extract-i18n/schema.json",
)

ts_library(
name = "build",
package_name = "@angular/build",
Expand All @@ -34,6 +39,7 @@ ts_library(
) + [
"//packages/angular/build:src/builders/application/schema.ts",
"//packages/angular/build:src/builders/dev-server/schema.ts",
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
],
data = glob(
include = [
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/build/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"implementation": "./src/builders/dev-server/index",
"schema": "./src/builders/dev-server/schema.json",
"description": "Execute a development server for an application."
},
"extract-i18n": {
"implementation": "./src/builders/extract-i18n/index",
"schema": "./src/builders/extract-i18n/schema.json",
"description": "Extract i18n messages from an application."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @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 type { ɵParsedMessage as LocalizeMessage } from '@angular/localize';
import type { MessageExtractor } from '@angular/localize/tools';
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import assert from 'node:assert';
import nodePath from 'node:path';
import { buildApplicationInternal } from '../application';
import type {
ApplicationBuilderExtensions,
ApplicationBuilderInternalOptions,
} from '../application/options';
import type { NormalizedExtractI18nOptions } from './options';

export async function extractMessages(
options: NormalizedExtractI18nOptions,
builderName: string,
context: BuilderContext,
extractorConstructor: typeof MessageExtractor,
extensions?: ApplicationBuilderExtensions,
): Promise<{
builderResult: BuilderOutput;
basePath: string;
messages: LocalizeMessage[];
useLegacyIds: boolean;
}> {
const messages: LocalizeMessage[] = [];

// Setup the build options for the application based on the buildTarget option
const buildOptions = (await context.validateOptions(
await context.getTargetOptions(options.buildTarget),
builderName,
)) as unknown as ApplicationBuilderInternalOptions;
buildOptions.optimization = false;
buildOptions.sourceMap = { scripts: true, vendor: true, styles: false };
buildOptions.localize = false;
buildOptions.budgets = undefined;
buildOptions.index = false;
buildOptions.serviceWorker = false;

buildOptions.ssr = false;
buildOptions.appShell = false;
buildOptions.prerender = false;

// Build the application with the build options
let builderResult;
try {
for await (const result of buildApplicationInternal(
buildOptions,
context,
{ write: false },
extensions,
)) {
builderResult = result;
break;
}

assert(builderResult !== undefined, 'Application builder did not provide a result.');
} catch (err) {
builderResult = {
success: false,
error: (err as Error).message,
};
}

// Extract messages from each output JavaScript file.
// Output files are only present on a successful build.
if (builderResult.outputFiles) {
// Store the JS and JS map files for lookup during extraction
const files = new Map<string, string>();
for (const outputFile of builderResult.outputFiles) {
if (outputFile.path.endsWith('.js')) {
files.set(outputFile.path, outputFile.text);
} else if (outputFile.path.endsWith('.js.map')) {
files.set(outputFile.path, outputFile.text);
}
}

// Setup the localize message extractor based on the in-memory files
const extractor = setupLocalizeExtractor(extractorConstructor, files, context);

// Attempt extraction of all output JS files
for (const filePath of files.keys()) {
if (!filePath.endsWith('.js')) {
continue;
}

const fileMessages = extractor.extractMessages(filePath);
messages.push(...fileMessages);
}
}

return {
builderResult,
basePath: context.workspaceRoot,
messages,
// Legacy i18n identifiers are not supported with the new application builder
useLegacyIds: false,
};
}

function setupLocalizeExtractor(
extractorConstructor: typeof MessageExtractor,
files: Map<string, string>,
context: BuilderContext,
): MessageExtractor {
// Setup a virtual file system instance for the extractor
// * MessageExtractor itself uses readFile, relative and resolve
// * Internal SourceFileLoader (sourcemap support) uses dirname, exists, readFile, and resolve
const filesystem = {
readFile(path: string): string {
// Output files are stored as relative to the workspace root
const requestedPath = nodePath.relative(context.workspaceRoot, path);

const content = files.get(requestedPath);
if (content === undefined) {
throw new Error('Unknown file requested: ' + requestedPath);
}

return content;
},
relative(from: string, to: string): string {
return nodePath.relative(from, to);
},
resolve(...paths: string[]): string {
return nodePath.resolve(...paths);
},
exists(path: string): boolean {
// Output files are stored as relative to the workspace root
const requestedPath = nodePath.relative(context.workspaceRoot, path);

return files.has(requestedPath);
},
dirname(path: string): string {
return nodePath.dirname(path);
},
};

const logger = {
// level 2 is warnings
level: 2,
debug(...args: string[]): void {
// eslint-disable-next-line no-console
console.debug(...args);
},
info(...args: string[]): void {
context.logger.info(args.join(''));
},
warn(...args: string[]): void {
context.logger.warn(args.join(''));
},
error(...args: string[]): void {
context.logger.error(args.join(''));
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractor = new extractorConstructor(filesystem as any, logger, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
basePath: context.workspaceRoot as any,
useSourceMaps: true,
});

return extractor;
}
165 changes: 165 additions & 0 deletions packages/angular/build/src/builders/extract-i18n/builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @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 type { Diagnostics } from '@angular/localize/tools';
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import fs from 'node:fs';
import path from 'node:path';
import { loadEsmModule } from '../../utils/load-esm';
import { assertCompatibleAngularVersion } from '../../utils/version';
import type { ApplicationBuilderExtensions } from '../application/options';
import { normalizeOptions } from './options';
import { Schema as ExtractI18nBuilderOptions, Format } from './schema';

/**
* @experimental Direct usage of this function is considered experimental.
*/
export async function execute(
options: ExtractI18nBuilderOptions,
context: BuilderContext,
extensions?: ApplicationBuilderExtensions,
): Promise<BuilderOutput> {
// Determine project name from builder context target
const projectName = context.target?.project;
if (!projectName) {
context.logger.error(`The 'extract-i18n' builder requires a target to be specified.`);

return { success: false };
}

// Check Angular version.
assertCompatibleAngularVersion(context.workspaceRoot);

// Load the Angular localize package.
// The package is a peer dependency and might not be present
let localizeToolsModule;
try {
localizeToolsModule =
await loadEsmModule<typeof import('@angular/localize/tools')>('@angular/localize/tools');
} catch {
return {
success: false,
error:
`i18n extraction requires the '@angular/localize' package.` +
` You can add it by using 'ng add @angular/localize'.`,
};
}

// Normalize options
const normalizedOptions = await normalizeOptions(context, projectName, options);
const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget);

// Extract messages based on configured builder
const { extractMessages } = await import('./application-extraction');
const extractionResult = await extractMessages(
normalizedOptions,
builderName,
context,
localizeToolsModule.MessageExtractor,
extensions,
);

// Return the builder result if it failed
if (!extractionResult.builderResult.success) {
return extractionResult.builderResult;
}

// Perform duplicate message checks
const { checkDuplicateMessages } = localizeToolsModule;

// The filesystem is used to create a relative path for each file
// from the basePath. This relative path is then used in the error message.
const checkFileSystem = {
relative(from: string, to: string): string {
return path.relative(from, to);
},
};
const diagnostics = checkDuplicateMessages(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkFileSystem as any,
extractionResult.messages,
'warning',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extractionResult.basePath as any,
);
if (diagnostics.messages.length > 0) {
context.logger.warn(diagnostics.formatDiagnostics(''));
}

// Serialize all extracted messages
const serializer = await createSerializer(
localizeToolsModule,
normalizedOptions.format,
normalizedOptions.i18nOptions.sourceLocale,
extractionResult.basePath,
extractionResult.useLegacyIds,
diagnostics,
);
const content = serializer.serialize(extractionResult.messages);

// Ensure directory exists
const outputPath = path.dirname(normalizedOptions.outFile);
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}

// Write translation file
fs.writeFileSync(normalizedOptions.outFile, content);

if (normalizedOptions.progress) {
context.logger.info(`Extraction Complete. (Messages: ${extractionResult.messages.length})`);
}

return { success: true, outputPath: normalizedOptions.outFile };
}

async function createSerializer(
localizeToolsModule: typeof import('@angular/localize/tools'),
format: Format,
sourceLocale: string,
basePath: string,
useLegacyIds: boolean,
diagnostics: Diagnostics,
) {
const {
XmbTranslationSerializer,
LegacyMessageIdMigrationSerializer,
ArbTranslationSerializer,
Xliff1TranslationSerializer,
Xliff2TranslationSerializer,
SimpleJsonTranslationSerializer,
} = localizeToolsModule;

switch (format) {
case Format.Xmb:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new XmbTranslationSerializer(basePath as any, useLegacyIds);
case Format.Xlf:
case Format.Xlif:
case Format.Xliff:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
case Format.Xlf2:
case Format.Xliff2:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
case Format.Json:
return new SimpleJsonTranslationSerializer(sourceLocale);
case Format.LegacyMigrate:
return new LegacyMessageIdMigrationSerializer(diagnostics);
case Format.Arb:
const fileSystem = {
relative(from: string, to: string): string {
return path.relative(from, to);
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new ArbTranslationSerializer(sourceLocale, basePath as any, fileSystem as any);
}
}
Loading

0 comments on commit 0b03829

Please sign in to comment.