@@ -10,6 +10,16 @@ import * as fs from 'fs';
10
10
11
11
const Critters : typeof import ( 'critters' ) . default = require ( 'critters' ) ;
12
12
13
+ /**
14
+ * Pattern used to extract the media query set by Critters in an `onload` handler.
15
+ */
16
+ const MEDIA_SET_HANDLER_PATTERN = / ^ t h i s \. m e d i a = [ " ' ] ( .* ) [ " ' ] ; ? $ / ;
17
+
18
+ /**
19
+ * Name of the attribute used to save the Critters media query so it can be re-assigned on load.
20
+ */
21
+ const CSP_MEDIA_ATTR = 'ngCspMedia' ;
22
+
13
23
export interface InlineCriticalCssProcessOptions {
14
24
outputPath : string ;
15
25
}
@@ -20,9 +30,36 @@ export interface InlineCriticalCssProcessorOptions {
20
30
readAsset ?: ( path : string ) => Promise < string > ;
21
31
}
22
32
33
+ /** Partial representation of an `HTMLElement`. */
34
+ interface PartialHTMLElement {
35
+ getAttribute ( name : string ) : string | null ;
36
+ setAttribute ( name : string , value : string ) : void ;
37
+ removeAttribute ( name : string ) : void ;
38
+ appendChild ( child : PartialHTMLElement ) : void ;
39
+ textContent : string ;
40
+ }
41
+
42
+ /** Partial representation of an HTML `Document`. */
43
+ interface PartialDocument {
44
+ head : PartialHTMLElement ;
45
+ createElement ( tagName : string ) : PartialHTMLElement ;
46
+ querySelector ( selector : string ) : PartialHTMLElement | null ;
47
+ }
48
+
49
+ /** Signature of the `Critters.embedLinkedStylesheet` method. */
50
+ type EmbedLinkedStylesheetFn = (
51
+ link : PartialHTMLElement ,
52
+ document : PartialDocument ,
53
+ ) => Promise < unknown > ;
54
+
23
55
class CrittersExtended extends Critters {
24
56
readonly warnings : string [ ] = [ ] ;
25
57
readonly errors : string [ ] = [ ] ;
58
+ private initialEmbedLinkedStylesheet : EmbedLinkedStylesheetFn ;
59
+ private addedCspScriptsDocuments = new WeakSet < PartialDocument > ( ) ;
60
+
61
+ // Inherited from `Critters`, but not exposed in the typings.
62
+ protected embedLinkedStylesheet ! : EmbedLinkedStylesheetFn ;
26
63
27
64
constructor (
28
65
private readonly optionsExtended : InlineCriticalCssProcessorOptions &
@@ -41,17 +78,82 @@ class CrittersExtended extends Critters {
41
78
pruneSource : false ,
42
79
reduceInlineStyles : false ,
43
80
mergeStylesheets : false ,
81
+ // Note: if `preload` changes to anything other than `media`, the logic in
82
+ // `embedLinkedStylesheetOverride` will have to be updated.
44
83
preload : 'media' ,
45
84
noscriptFallback : true ,
46
85
inlineFonts : true ,
47
86
} ) ;
87
+
88
+ // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in
89
+ // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't
90
+ // allow for `super` to be cast to a different type.
91
+ this . initialEmbedLinkedStylesheet = this . embedLinkedStylesheet ;
92
+ this . embedLinkedStylesheet = this . embedLinkedStylesheetOverride ;
48
93
}
49
94
50
95
public override readFile ( path : string ) : Promise < string > {
51
96
const readAsset = this . optionsExtended . readAsset ;
52
97
53
98
return readAsset ? readAsset ( path ) : fs . promises . readFile ( path , 'utf-8' ) ;
54
99
}
100
+
101
+ /**
102
+ * Override of the Critters `embedLinkedStylesheet` method
103
+ * that makes it work with Angular's CSP APIs.
104
+ */
105
+ private embedLinkedStylesheetOverride : EmbedLinkedStylesheetFn = async ( link , document ) => {
106
+ const returnValue = await this . initialEmbedLinkedStylesheet ( link , document ) ;
107
+ const crittersMedia = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
108
+
109
+ if ( crittersMedia ) {
110
+ // HTML attribute are case-insensitive, but the parser
111
+ // used by Critters appears to be case-sensitive.
112
+ const nonceElement = document . querySelector ( '[ngCspNonce], [ngcspnonce]' ) ;
113
+ const cspNonce =
114
+ nonceElement ?. getAttribute ( 'ngCspNonce' ) || nonceElement ?. getAttribute ( 'ngcspnonce' ) ;
115
+
116
+ if ( cspNonce ) {
117
+ // If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce,
118
+ // we have to remove the handler, because it's incompatible with CSP. We save the value
119
+ // in a different attribute and we generate a script tag with the nonce that uses
120
+ // `addEventListener` to apply the media query instead.
121
+ link . removeAttribute ( 'onload' ) ;
122
+ link . setAttribute ( CSP_MEDIA_ATTR , crittersMedia [ 1 ] ) ;
123
+ this . conditionallyInsertCspLoadingScript ( document , cspNonce ) ;
124
+ }
125
+ }
126
+
127
+ return returnValue ;
128
+ } ;
129
+
130
+ /**
131
+ * Inserts the `script` tag that swaps the critical CSS at runtime,
132
+ * if one hasn't been inserted into the document already.
133
+ */
134
+ private conditionallyInsertCspLoadingScript ( document : PartialDocument , nonce : string ) {
135
+ if ( ! this . addedCspScriptsDocuments . has ( document ) ) {
136
+ const script = document . createElement ( 'script' ) ;
137
+ script . setAttribute ( 'nonce' , nonce ) ;
138
+ script . textContent = [
139
+ `(function() {` ,
140
+ // Save the `children` in a variable since they're a live DOM node collection.
141
+ ` var children = document.head.children;` ,
142
+ // Declare `onLoad` outside the loop to avoid leaking memory.
143
+ // Can't be an arrow function, because we need `this` to refer to the DOM node.
144
+ ` function onLoad() {this.media = this.getAttribute('${ CSP_MEDIA_ATTR } ');}` ,
145
+ ` for (var i = 0; i < children.length; i++) {` ,
146
+ ` var child = children[i];` ,
147
+ ` child.hasAttribute('${ CSP_MEDIA_ATTR } ') && child.addEventListener('load', onLoad);` ,
148
+ ` }` ,
149
+ `})();` ,
150
+ ] . join ( '\n' ) ;
151
+ // Append the script to the head since it needs to
152
+ // run as early as possible, after the `link` tags.
153
+ document . head . appendChild ( script ) ;
154
+ this . addedCspScriptsDocuments . add ( document ) ;
155
+ }
156
+ }
55
157
}
56
158
57
159
export class InlineCriticalCssProcessor {
0 commit comments