diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts new file mode 100644 index 000000000000..8c2cf1d2e59f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts @@ -0,0 +1,102 @@ +/** + * @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: "fonts.inline"', () => { + beforeEach(async () => { + await harness.modifyFile('/src/index.html', (content) => + content.replace( + '', + ``, + ), + ); + + await harness.writeFile( + 'src/styles.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + + await harness.writeFile( + 'src/app/app.component.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + }); + + it(`should not inline fonts when fonts optimization is set to false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + } + }); + + it(`should inline fonts when fonts optimization is unset`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: undefined, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + + it(`should inline fonts when fonts optimization is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: true, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts index 3f744987eba8..ce1d68ea9655 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts @@ -88,7 +88,7 @@ export class ComponentStylesheetBundler { namespace, }; }); - build.onLoad({ filter: /^css;/, namespace }, async () => { + build.onLoad({ filter: /^css;/, namespace }, () => { return { contents: data, loader: 'css', diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/compiler-plugin-options.ts index d1de9017d3b2..78035e9e0349 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/compiler-plugin-options.ts @@ -33,7 +33,9 @@ export function createCompilerPluginOptions( advancedOptimizations, inlineStyleLanguage, jit, + cacheOptions, tailwindConfiguration, + publicPath, } = options; return { @@ -52,6 +54,7 @@ export function createCompilerPluginOptions( // Component stylesheet options styleOptions: { workspaceRoot, + inlineFonts: !!optimizationOptions.fonts.inline, optimization: !!optimizationOptions.styles.minify, sourcemap: // Hidden component stylesheet sourcemaps are inaccessible which is effectively @@ -65,7 +68,8 @@ export function createCompilerPluginOptions( inlineStyleLanguage, preserveSymlinks, tailwindConfiguration, - publicPath: options.publicPath, + cacheOptions, + publicPath, }, }; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/global-styles.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/global-styles.ts index 640e6bcd05fc..a948b1808e94 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/global-styles.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/global-styles.ts @@ -27,6 +27,8 @@ export function createGlobalStylesBundleOptions( externalDependencies, stylePreprocessorOptions, tailwindConfiguration, + cacheOptions, + publicPath, } = options; const namespace = 'angular:styles/global'; @@ -49,6 +51,7 @@ export function createGlobalStylesBundleOptions( { workspaceRoot, optimization: !!optimizationOptions.styles.minify, + inlineFonts: !!optimizationOptions.fonts.inline, sourcemap: !!sourcemapOptions.styles, preserveSymlinks, target, @@ -61,7 +64,8 @@ export function createGlobalStylesBundleOptions( }, includePaths: stylePreprocessorOptions?.includePaths, tailwindConfiguration, - publicPath: options.publicPath, + cacheOptions, + publicPath, }, loadCache, ); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts index 7f6c1e5841bd..e63461bd78fd 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import type { BuildOptions } from 'esbuild'; +import type { BuildOptions, Plugin } from 'esbuild'; import path from 'node:path'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; import { LoadResultCache } from '../load-result-cache'; +import { createCssInlineFontsPlugin } from './css-inline-fonts-plugin'; import { CssStylesheetLanguage } from './css-language'; import { createCssResourcePlugin } from './css-resource-plugin'; import { LessStylesheetLanguage } from './less-language'; @@ -18,6 +20,7 @@ import { StylesheetPluginFactory } from './stylesheet-plugin-factory'; export interface BundleStylesheetOptions { workspaceRoot: string; optimization: boolean; + inlineFonts: boolean; preserveSymlinks?: boolean; sourcemap: boolean | 'external' | 'inline'; outputNames: { bundles: string; media: string }; @@ -26,6 +29,7 @@ export interface BundleStylesheetOptions { target: string[]; tailwindConfiguration?: { file: string; package: string }; publicPath?: string; + cacheOptions: NormalizedCachedOptions; } export function createStylesheetBundleOptions( @@ -48,6 +52,17 @@ export function createStylesheetBundleOptions( cache, ); + const plugins: Plugin[] = [ + pluginFactory.create(SassStylesheetLanguage), + pluginFactory.create(LessStylesheetLanguage), + pluginFactory.create(CssStylesheetLanguage), + createCssResourcePlugin(cache), + ]; + + if (options.inlineFonts) { + plugins.push(createCssInlineFontsPlugin({ cache, cacheOptions: options.cacheOptions })); + } + return { absWorkingDir: options.workspaceRoot, bundle: true, @@ -66,11 +81,6 @@ export function createStylesheetBundleOptions( publicPath: options.publicPath, conditions: ['style', 'sass', 'less'], mainFields: ['style', 'sass'], - plugins: [ - pluginFactory.create(SassStylesheetLanguage), - pluginFactory.create(LessStylesheetLanguage), - pluginFactory.create(CssStylesheetLanguage), - createCssResourcePlugin(cache), - ], + plugins, }; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts new file mode 100644 index 000000000000..cd009339b372 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts @@ -0,0 +1,77 @@ +/** + * @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 { Plugin, PluginBuild } from 'esbuild'; +import { InlineFontsProcessor } from '../../../utils/index-file/inline-fonts'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * Options for the createCssInlineFontsPlugin + * @see createCssInlineFontsPlugin + */ +export interface CssInlineFontsPluginOptions { + /** Disk cache normalized options */ + cacheOptions?: NormalizedCachedOptions; + /** Load results cache. */ + cache?: LoadResultCache; +} + +/** + * Creates an esbuild {@link Plugin} that inlines fonts imported via import-rule. + * within the build configuration. + */ +export function createCssInlineFontsPlugin({ + cache, + cacheOptions, +}: CssInlineFontsPluginOptions): Plugin { + return { + name: 'angular-css-inline-fonts-plugin', + setup(build: PluginBuild): void { + const inlineFontsProcessor = new InlineFontsProcessor({ cache: cacheOptions, minify: false }); + + build.onResolve({ filter: /fonts\.googleapis\.com|use\.typekit\.net/ }, (args) => { + // Only attempt to resolve import-rule tokens which only exist inside CSS. + if (args.kind !== 'import-rule') { + return null; + } + + if (!inlineFontsProcessor.canInlineRequest(args.path)) { + return null; + } + + return { + path: args.path, + namespace: 'css-inline-fonts', + }; + }); + + build.onLoad( + { filter: /./, namespace: 'css-inline-fonts' }, + createCachedLoad(cache, async (args) => { + try { + return { + contents: await inlineFontsProcessor.processURL(args.path), + loader: 'css', + }; + } catch (error) { + return { + loader: 'css', + errors: [ + { + text: `Failed to inline external stylesheet '${args.path}'.`, + detail: error, + }, + ], + }; + } + }), + ); + }, + }; +} diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts index e26e38878269..c4775e9a4ac4 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts +++ b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts @@ -116,7 +116,7 @@ export class InlineFontsProcessor { continue; } - const content = await this.processHref(url); + const content = await this.processURL(url); if (content === undefined) { continue; } @@ -258,13 +258,18 @@ export class InlineFontsProcessor { return data; } - private async processHref(url: URL): Promise { - const provider = this.getFontProviderDetails(url); + async processURL(url: string | URL): Promise { + const normalizedURL = url instanceof URL ? url : this.createNormalizedUrl(url); + if (!normalizedURL) { + return; + } + + const provider = this.getFontProviderDetails(normalizedURL); if (!provider) { return undefined; } - let cssContent = await this.getResponse(url); + let cssContent = await this.getResponse(normalizedURL); if (this.options.minify) { cssContent = cssContent @@ -279,23 +284,28 @@ export class InlineFontsProcessor { return cssContent; } + canInlineRequest(url: string): boolean { + const normalizedUrl = this.createNormalizedUrl(url); + + return normalizedUrl ? !!this.getFontProviderDetails(normalizedUrl) : false; + } + private getFontProviderDetails(url: URL): FontProviderDetails | undefined { return SUPPORTED_PROVIDERS[url.hostname]; } private createNormalizedUrl(value: string): URL | undefined { // Need to convert '//' to 'https://' because the URL parser will fail with '//'. - const normalizedHref = value.startsWith('//') ? `https:${value}` : value; - if (!normalizedHref.startsWith('http')) { - // Non valid URL. - // Example: relative path styles.css. - return undefined; - } + const url = new URL(value.startsWith('//') ? `https:${value}` : value, 'resolve://'); - const url = new URL(normalizedHref); - // Force HTTPS protocol - url.protocol = 'https:'; + switch (url.protocol) { + case 'http:': + case 'https:': + url.protocol = 'https:'; - return url; + return url; + default: + return undefined; + } } }