diff --git a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts index e76244c140ab..e7a2b63e3238 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts @@ -52,10 +52,13 @@ export async function inlineI18n( try { for (const locale of options.i18nOptions.inlineLocales) { // A locale specific set of files is returned from the inliner. - const localeOutputFiles = await inliner.inlineForLocale( + const localeInlineResult = await inliner.inlineForLocale( locale, options.i18nOptions.locales[locale].translation, ); + const localeOutputFiles = localeInlineResult.outputFiles; + inlineResult.errors.push(...localeInlineResult.errors); + inlineResult.warnings.push(...localeInlineResult.warnings); const baseHref = getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts new file mode 100644 index 000000000000..93b90a6fc1ec --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts @@ -0,0 +1,224 @@ +/** + * @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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "i18nMissingTranslation"', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + }); + + it('should warn when i18nMissingTranslation is undefined (default)', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + i18nMissingTranslation: undefined, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should warn when i18nMissingTranslation is set to warning', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should error when i18nMissingTranslation is set to error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to ignore', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'ignore' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to error and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to warning and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + }); +}); + +const GOOD_TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; + +const MISSING_TRANSLATION_FILE_CONTENT = ` + + + + + + + + +`; diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts index 186236133182..552cd6ff1f43 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts @@ -60,14 +60,12 @@ export default async function inlineLocale(request: InlineRequest) { request, ); - // TODO: Return diagnostics - // TODO: Consider buffer transfer instead of string copying - const response = [{ file: request.filename, contents: result.code }]; - if (result.map) { - response.push({ file: request.filename + '.map', contents: result.map }); - } - - return response; + return { + file: request.filename, + code: result.code, + map: result.map, + messages: result.diagnostics.messages, + }; } /** diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts index 9efc37c338bf..465ca6546433 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import assert from 'node:assert'; import Piscina from 'piscina'; import { BuildOutputFile, BuildOutputFileType } from './bundler-context'; import { createOutputFileFromText } from './utils'; @@ -110,7 +111,7 @@ export class I18nInliner { async inlineForLocale( locale: string, translation: Record | undefined, - ): Promise { + ): Promise<{ outputFiles: BuildOutputFile[]; errors: string[]; warnings: string[] }> { // Request inlining for each file that contains localize calls const requests = []; for (const filename of this.#localizeFiles.keys()) { @@ -130,13 +131,36 @@ export class I18nInliner { const rawResults = await Promise.all(requests); // Convert raw results to output file objects and include all unmodified files - return [ - ...rawResults.flat().map(({ file, contents }) => - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - createOutputFileFromText(file, contents, this.#fileToType.get(file)!), - ), + const errors: string[] = []; + const warnings: string[] = []; + const outputFiles = [ + ...rawResults.flatMap(({ file, code, map, messages }) => { + const type = this.#fileToType.get(file); + assert(type !== undefined, 'localized file should always have a type' + file); + + const resultFiles = [createOutputFileFromText(file, code, type)]; + if (map) { + resultFiles.push(createOutputFileFromText(file + '.map', map, type)); + } + + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + + return resultFiles; + }), ...this.#unmodifiedFiles.map((file) => file.clone()), ]; + + return { + outputFiles, + errors, + warnings, + }; } /**