diff --git a/goldens/public-api/angular_devkit/build_angular/index.md b/goldens/public-api/angular_devkit/build_angular/index.md index 1414c35bca12..f983c0539626 100644 --- a/goldens/public-api/angular_devkit/build_angular/index.md +++ b/goldens/public-api/angular_devkit/build_angular/index.md @@ -37,6 +37,9 @@ export interface ApplicationBuilderOptions { i18nMissingTranslation?: I18NTranslation_2; index: IndexUnion_2; inlineStyleLanguage?: InlineStyleLanguage_2; + loader?: { + [key: string]: any; + }; localize?: Localize_2; namedChunks?: boolean; optimization?: OptimizationUnion_2; diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index d0dd336a9f79..5abae5922272 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -143,6 +143,20 @@ export async function normalizeOptions( } } + let loaderExtensions: Record | undefined; + if (options.loader) { + for (const [extension, value] of Object.entries(options.loader)) { + if (extension[0] !== '.' || /\.[cm]?[jt]sx?$/.test(extension)) { + continue; + } + if (value !== 'text' && value !== 'binary' && value !== 'file' && value !== 'empty') { + continue; + } + loaderExtensions ??= {}; + loaderExtensions[extension] = value; + } + } + const globalStyles: { name: string; files: string[]; initial: boolean }[] = []; if (options.styles?.length) { const { entryPoints: stylesheetEntrypoints, noInjectNames } = normalizeGlobalStyles( @@ -307,6 +321,7 @@ export async function normalizeOptions( budgets: budgets?.length ? budgets : undefined, publicPath: deployUrl ? deployUrl : undefined, plugins: plugins?.length ? plugins : undefined, + loaderExtensions, }; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/schema.json b/packages/angular_devkit/build_angular/src/builders/application/schema.json index ee9946773976..dba0d9a48953 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json @@ -199,6 +199,13 @@ } ] }, + "loader": { + "description": "Defines the type of loader to use with a specified file extension when used with a JavaScript `import`. `text` inlines the content as a string; `binary` inlines the content as a Uint8Array; `file` emits the file and provides the runtime location of the file; `empty` considers the content to be empty and not include it in bundles.", + "type": "object", + "patternProperties": { + "^\\.\\S+$": { "enum": ["text", "binary", "file", "empty"] } + } + }, "fileReplacements": { "description": "Replace compilation source files with other compilation source files in the build.", "type": "array", diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts new file mode 100644 index 000000000000..6a30a2359dae --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts @@ -0,0 +1,257 @@ +/** + * @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: "loader"', () => { + it('should error for an unknown file extension', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'No loader is configured for ".unknown" files: src/a.unknown', + ), + }), + ); + }); + + it('should not include content for file extension set to "empty"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'empty', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline text content for file extension set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'text', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for file extension set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: Uint8Array; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should emit an output file for file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should inline text content for `.txt` by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: undefined, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline text content for `.txt` by default when other extensions are defined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.txt': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an invalid loader name is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'invalid', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an extension does not start with a period', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + 'unknown': 'text', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index b228ef73c76a..f26c641da70e 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -320,6 +320,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu outputNames, preserveSymlinks, jit, + loaderExtensions, } = options; // Ensure unique hashes for i18n translation changes when using post-process inlining. @@ -367,6 +368,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu ...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined), 'ngJitMode': jit ? 'true' : 'false', }, + loader: loaderExtensions, footer, publicPath: options.publicPath, };