66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- import Beasties from 'beasties' ;
109import { readFile } from 'node:fs/promises' ;
1110
1211/**
@@ -95,147 +94,184 @@ interface PartialDocument {
9594 querySelector ( selector : string ) : PartialHTMLElement | null ;
9695}
9796
98- /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
99-
100- // We use Typescript declaration merging because `embedLinkedStylesheet` it's not declared in
101- // the `Beasties` types which means that we can't call the `super` implementation.
10297interface BeastiesBase {
10398 embedLinkedStylesheet ( link : PartialHTMLElement , document : PartialDocument ) : Promise < unknown > ;
99+ readFile ( path : string ) : Promise < string > ;
100+ process ( html : string ) : Promise < string > ;
104101}
105- class BeastiesBase extends Beasties { }
106- /* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */
107-
108- class BeastiesExtended extends BeastiesBase {
109- readonly warnings : string [ ] = [ ] ;
110- readonly errors : string [ ] = [ ] ;
111- private addedCspScriptsDocuments = new WeakSet < PartialDocument > ( ) ;
112- private documentNonces = new WeakMap < PartialDocument , string | null > ( ) ;
113-
114- constructor (
115- private readonly optionsExtended : InlineCriticalCssProcessorOptions &
116- InlineCriticalCssProcessOptions ,
117- ) {
118- super ( {
119- logger : {
120- warn : ( s : string ) => this . warnings . push ( s ) ,
121- error : ( s : string ) => this . errors . push ( s ) ,
122- info : ( ) => { } ,
123- } ,
124- logLevel : 'warn' ,
125- path : optionsExtended . outputPath ,
126- publicPath : optionsExtended . deployUrl ,
127- compress : ! ! optionsExtended . minify ,
128- pruneSource : false ,
129- reduceInlineStyles : false ,
130- mergeStylesheets : false ,
131- // Note: if `preload` changes to anything other than `media`, the logic in
132- // `embedLinkedStylesheet` will have to be updated.
133- preload : 'media' ,
134- noscriptFallback : true ,
135- inlineFonts : true ,
136- } ) ;
137- }
138102
139- public override readFile ( path : string ) : Promise < string > {
140- const readAsset = this . optionsExtended . readAsset ;
103+ interface BeastiesExtendedInstance {
104+ readonly warnings : string [ ] ;
105+ readonly errors : string [ ] ;
106+ process ( html : string ) : Promise < string > ;
107+ }
141108
142- return readAsset ? readAsset ( path ) : readFile ( path , 'utf-8' ) ;
109+ let beastiesClassPromise :
110+ | Promise <
111+ new (
112+ options : InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions ,
113+ ) => BeastiesExtendedInstance
114+ >
115+ | undefined ;
116+
117+ async function getBeastiesClass ( ) : Promise <
118+ new (
119+ options : InlineCriticalCssProcessorOptions & InlineCriticalCssProcessOptions ,
120+ ) => BeastiesExtendedInstance
121+ > {
122+ if ( beastiesClassPromise ) {
123+ return beastiesClassPromise ;
143124 }
144125
145- /**
146- * Override of the Beasties `embedLinkedStylesheet` method
147- * that makes it work with Angular's CSP APIs.
148- */
149- override async embedLinkedStylesheet (
150- link : PartialHTMLElement ,
151- document : PartialDocument ,
152- ) : Promise < unknown > {
153- if ( link . getAttribute ( 'media' ) === 'print' && link . next ?. name === 'noscript' ) {
154- // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
155- // NB: this is only needed for the webpack based builders.
156- const media = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
157- if ( media ) {
158- link . removeAttribute ( 'onload' ) ;
159- link . setAttribute ( 'media' , media [ 1 ] ) ;
160- link ?. next ?. remove ( ) ;
161- }
162- }
163-
164- const returnValue = await super . embedLinkedStylesheet ( link , document ) ;
165- const cspNonce = this . findCspNonce ( document ) ;
166-
167- if ( cspNonce || this . optionsExtended . autoCsp ) {
168- const beastiesMedia = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
126+ beastiesClassPromise = import ( 'beasties' ) . then ( ( { default : Beasties } ) => {
127+ return class BeastiesExtended
128+ extends ( Beasties as unknown as new ( options : unknown ) => BeastiesBase )
129+ implements BeastiesExtendedInstance
130+ {
131+ private _warnings ?: string [ ] ;
132+ private _errors ?: string [ ] ;
169133
170- if ( beastiesMedia ) {
171- // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce,
172- // we have to remove the handler, because it's incompatible with CSP. We save the value
173- // in a different attribute and we generate a script tag with the nonce that uses
174- // `addEventListener` to apply the media query instead.
175- link . removeAttribute ( 'onload' ) ;
176- link . setAttribute ( CSP_MEDIA_ATTR , beastiesMedia [ 1 ] ) ;
177- this . conditionallyInsertCspLoadingScript ( document , cspNonce , link ) ;
134+ get warnings ( ) : string [ ] {
135+ return ( this . _warnings ??= [ ] ) ;
178136 }
179137
180- // Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't
181- // a way of doing that at the moment so we fall back to doing it any time a `link` tag is
182- // inserted. We mitigate it by only iterating the direct children of the `<head>` which
183- // should be pretty shallow.
184- if ( cspNonce ) {
185- document . head . children . forEach ( ( child ) => {
186- if ( child . tagName === 'style' && ! child . hasAttribute ( 'nonce' ) ) {
187- child . setAttribute ( 'nonce' , cspNonce ) ;
188- }
138+ get errors ( ) : string [ ] {
139+ return ( this . _errors ??= [ ] ) ;
140+ }
141+ private addedCspScriptsDocuments = new WeakSet < PartialDocument > ( ) ;
142+ private documentNonces = new WeakMap < PartialDocument , string | null > ( ) ;
143+
144+ constructor (
145+ private readonly optionsExtended : InlineCriticalCssProcessorOptions &
146+ InlineCriticalCssProcessOptions ,
147+ ) {
148+ super ( {
149+ logger : {
150+ warn : ( s : string ) => this . warnings . push ( s ) ,
151+ error : ( s : string ) => this . errors . push ( s ) ,
152+ info : ( ) => { } ,
153+ } ,
154+ logLevel : 'warn' ,
155+ path : optionsExtended . outputPath ,
156+ publicPath : optionsExtended . deployUrl ,
157+ compress : ! ! optionsExtended . minify ,
158+ pruneSource : false ,
159+ reduceInlineStyles : false ,
160+ mergeStylesheets : false ,
161+ // Note: if `preload` changes to anything other than `media`, the logic in
162+ // `embedLinkedStylesheet` will have to be updated.
163+ preload : 'media' ,
164+ noscriptFallback : true ,
165+ inlineFonts : true ,
189166 } ) ;
190167 }
191- }
192168
193- return returnValue ;
194- }
169+ public override readFile ( path : string ) : Promise < string > {
170+ const readAsset = this . optionsExtended . readAsset ;
195171
196- /**
197- * Finds the CSP nonce for a specific document.
198- */
199- private findCspNonce ( document : PartialDocument ) : string | null {
200- if ( this . documentNonces . has ( document ) ) {
201- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
202- return this . documentNonces . get ( document ) ! ;
203- }
172+ return readAsset ? readAsset ( path ) : readFile ( path , 'utf-8' ) ;
173+ }
204174
205- // HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive.
206- const nonceElement = document . querySelector ( '[ngCspNonce], [ngcspnonce]' ) ;
207- const cspNonce =
208- nonceElement ?. getAttribute ( 'ngCspNonce' ) || nonceElement ?. getAttribute ( 'ngcspnonce' ) || null ;
175+ /**
176+ * Override of the Beasties `embedLinkedStylesheet` method
177+ * that makes it work with Angular's CSP APIs.
178+ */
179+ override async embedLinkedStylesheet (
180+ link : PartialHTMLElement ,
181+ document : PartialDocument ,
182+ ) : Promise < unknown > {
183+ if ( link . getAttribute ( 'media' ) === 'print' && link . next ?. name === 'noscript' ) {
184+ // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
185+ // NB: this is only needed for the webpack based builders.
186+ const media = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
187+ if ( media ) {
188+ link . removeAttribute ( 'onload' ) ;
189+ link . setAttribute ( 'media' , media [ 1 ] ) ;
190+ link ?. next ?. remove ( ) ;
191+ }
192+ }
193+
194+ const returnValue = await super . embedLinkedStylesheet ( link , document ) ;
195+ const cspNonce = this . findCspNonce ( document ) ;
196+
197+ if ( cspNonce || this . optionsExtended . autoCsp ) {
198+ const beastiesMedia = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
199+
200+ if ( beastiesMedia ) {
201+ // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce,
202+ // we have to remove the handler, because it's incompatible with CSP. We save the value
203+ // in a different attribute and we generate a script tag with the nonce that uses
204+ // `addEventListener` to apply the media query instead.
205+ link . removeAttribute ( 'onload' ) ;
206+ link . setAttribute ( CSP_MEDIA_ATTR , beastiesMedia [ 1 ] ) ;
207+ this . conditionallyInsertCspLoadingScript ( document , cspNonce , link ) ;
208+ }
209209
210- this . documentNonces . set ( document , cspNonce ) ;
210+ // Ideally we would hook in at the time Beasties inserts the `style` tags, but there isn't
211+ // a way of doing that at the moment so we fall back to doing it any time a `link` tag is
212+ // inserted. We mitigate it by only iterating the direct children of the `<head>` which
213+ // should be pretty shallow.
214+ if ( cspNonce ) {
215+ document . head . children . forEach ( ( child ) => {
216+ if ( child . tagName === 'style' && ! child . hasAttribute ( 'nonce' ) ) {
217+ child . setAttribute ( 'nonce' , cspNonce ) ;
218+ }
219+ } ) ;
220+ }
221+ }
211222
212- return cspNonce ;
213- }
223+ return returnValue ;
224+ }
214225
215- /**
216- * Inserts the `script` tag that swaps the critical CSS at runtime,
217- * if one hasn't been inserted into the document already.
218- */
219- private conditionallyInsertCspLoadingScript (
220- document : PartialDocument ,
221- nonce : string | null ,
222- link : PartialHTMLElement ,
223- ) : void {
224- if ( this . addedCspScriptsDocuments . has ( document ) ) {
225- return ;
226- }
226+ /**
227+ * Finds the CSP nonce for a specific document.
228+ */
229+ private findCspNonce ( document : PartialDocument ) : string | null {
230+ if ( this . documentNonces . has ( document ) ) {
231+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
232+ return this . documentNonces . get ( document ) ! ;
233+ }
234+
235+ // HTML attribute are case-insensitive, but the parser used by Beasties is case-sensitive.
236+ const nonceElement = document . querySelector ( '[ngCspNonce], [ngcspnonce]' ) ;
237+ const cspNonce =
238+ nonceElement ?. getAttribute ( 'ngCspNonce' ) ||
239+ nonceElement ?. getAttribute ( 'ngcspnonce' ) ||
240+ null ;
241+
242+ this . documentNonces . set ( document , cspNonce ) ;
243+
244+ return cspNonce ;
245+ }
227246
228- const script = document . createElement ( 'script' ) ;
229- script . textContent = LINK_LOAD_SCRIPT_CONTENT ;
230- if ( nonce ) {
231- script . setAttribute ( 'nonce' , nonce ) ;
232- }
247+ /**
248+ * Inserts the `script` tag that swaps the critical CSS at runtime,
249+ * if one hasn't been inserted into the document already.
250+ */
251+ private conditionallyInsertCspLoadingScript (
252+ document : PartialDocument ,
253+ nonce : string | null ,
254+ link : PartialHTMLElement ,
255+ ) : void {
256+ if ( this . addedCspScriptsDocuments . has ( document ) ) {
257+ return ;
258+ }
259+
260+ const script = document . createElement ( 'script' ) ;
261+ script . textContent = LINK_LOAD_SCRIPT_CONTENT ;
262+ if ( nonce ) {
263+ script . setAttribute ( 'nonce' , nonce ) ;
264+ }
265+
266+ // Prepend the script to the head since it needs to
267+ // run as early as possible, before the `link` tags.
268+ document . head . insertBefore ( script , link ) ;
269+ this . addedCspScriptsDocuments . add ( document ) ;
270+ }
271+ } ;
272+ } ) ;
233273
234- // Prepend the script to the head since it needs to
235- // run as early as possible, before the `link` tags.
236- document . head . insertBefore ( script , link ) ;
237- this . addedCspScriptsDocuments . add ( document ) ;
238- }
274+ return beastiesClassPromise ;
239275}
240276
241277export class InlineCriticalCssProcessor {
@@ -245,7 +281,8 @@ export class InlineCriticalCssProcessor {
245281 html : string ,
246282 options : InlineCriticalCssProcessOptions ,
247283 ) : Promise < { content : string ; warnings : string [ ] ; errors : string [ ] } > {
248- const beasties = new BeastiesExtended ( { ...this . options , ...options } ) ;
284+ const BeastiesClass = await getBeastiesClass ( ) ;
285+ const beasties = new BeastiesClass ( { ...this . options , ...options } ) ;
249286 const content = await beasties . process ( html ) ;
250287
251288 return {
0 commit comments