@@ -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,40 @@ 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
+ hasAttribute ( name : string ) : boolean ;
38
+ removeAttribute ( name : string ) : void ;
39
+ appendChild ( child : PartialHTMLElement ) : void ;
40
+ textContent : string ;
41
+ tagName : string | null ;
42
+ children : PartialHTMLElement [ ] ;
43
+ }
44
+
45
+ /** Partial representation of an HTML `Document`. */
46
+ interface PartialDocument {
47
+ head : PartialHTMLElement ;
48
+ createElement ( tagName : string ) : PartialHTMLElement ;
49
+ querySelector ( selector : string ) : PartialHTMLElement | null ;
50
+ }
51
+
52
+ /** Signature of the `Critters.embedLinkedStylesheet` method. */
53
+ type EmbedLinkedStylesheetFn = (
54
+ link : PartialHTMLElement ,
55
+ document : PartialDocument ,
56
+ ) => Promise < unknown > ;
57
+
23
58
class CrittersExtended extends Critters {
24
59
readonly warnings : string [ ] = [ ] ;
25
60
readonly errors : string [ ] = [ ] ;
61
+ private initialEmbedLinkedStylesheet : EmbedLinkedStylesheetFn ;
62
+ private addedCspScriptsDocuments = new WeakSet < PartialDocument > ( ) ;
63
+ private documentNonces = new WeakMap < PartialDocument , string | null > ( ) ;
64
+
65
+ // Inherited from `Critters`, but not exposed in the typings.
66
+ protected embedLinkedStylesheet ! : EmbedLinkedStylesheetFn ;
26
67
27
68
constructor (
28
69
private readonly optionsExtended : InlineCriticalCssProcessorOptions &
@@ -41,17 +82,112 @@ class CrittersExtended extends Critters {
41
82
pruneSource : false ,
42
83
reduceInlineStyles : false ,
43
84
mergeStylesheets : false ,
85
+ // Note: if `preload` changes to anything other than `media`, the logic in
86
+ // `embedLinkedStylesheetOverride` will have to be updated.
44
87
preload : 'media' ,
45
88
noscriptFallback : true ,
46
89
inlineFonts : true ,
47
90
} ) ;
91
+
92
+ // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in
93
+ // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't
94
+ // allow for `super` to be cast to a different type.
95
+ this . initialEmbedLinkedStylesheet = this . embedLinkedStylesheet ;
96
+ this . embedLinkedStylesheet = this . embedLinkedStylesheetOverride ;
48
97
}
49
98
50
99
public override readFile ( path : string ) : Promise < string > {
51
100
const readAsset = this . optionsExtended . readAsset ;
52
101
53
102
return readAsset ? readAsset ( path ) : fs . promises . readFile ( path , 'utf-8' ) ;
54
103
}
104
+
105
+ /**
106
+ * Override of the Critters `embedLinkedStylesheet` method
107
+ * that makes it work with Angular's CSP APIs.
108
+ */
109
+ private embedLinkedStylesheetOverride : EmbedLinkedStylesheetFn = async ( link , document ) => {
110
+ const returnValue = await this . initialEmbedLinkedStylesheet ( link , document ) ;
111
+ const cspNonce = this . findCspNonce ( document ) ;
112
+
113
+ if ( cspNonce ) {
114
+ const crittersMedia = link . getAttribute ( 'onload' ) ?. match ( MEDIA_SET_HANDLER_PATTERN ) ;
115
+
116
+ if ( crittersMedia ) {
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
+ // Ideally we would hook in at the time Critters inserts the `style` tags, but there isn't
127
+ // a way of doing that at the moment so we fall back to doing it any time a `link` tag is
128
+ // inserted. We mitigate it by only iterating the direct children of the `<head>` which
129
+ // should be pretty shallow.
130
+ document . head . children . forEach ( ( child ) => {
131
+ if ( child . tagName === 'style' && ! child . hasAttribute ( 'nonce' ) ) {
132
+ child . setAttribute ( 'nonce' , cspNonce ) ;
133
+ }
134
+ } ) ;
135
+ }
136
+
137
+ return returnValue ;
138
+ } ;
139
+
140
+ /**
141
+ * Finds the CSP nonce for a specific document.
142
+ */
143
+ private findCspNonce ( document : PartialDocument ) : string | null {
144
+ if ( this . documentNonces . has ( document ) ) {
145
+ return this . documentNonces . get ( document ) ?? null ;
146
+ }
147
+
148
+ // HTML attribute are case-insensitive, but the parser used by Critters is case-sensitive.
149
+ const nonceElement = document . querySelector ( '[ngCspNonce], [ngcspnonce]' ) ;
150
+ const cspNonce =
151
+ nonceElement ?. getAttribute ( 'ngCspNonce' ) || nonceElement ?. getAttribute ( 'ngcspnonce' ) || null ;
152
+
153
+ this . documentNonces . set ( document , cspNonce ) ;
154
+
155
+ return cspNonce ;
156
+ }
157
+
158
+ /**
159
+ * Inserts the `script` tag that swaps the critical CSS at runtime,
160
+ * if one hasn't been inserted into the document already.
161
+ */
162
+ private conditionallyInsertCspLoadingScript ( document : PartialDocument , nonce : string ) {
163
+ if ( this . addedCspScriptsDocuments . has ( document ) ) {
164
+ return ;
165
+ }
166
+
167
+ const script = document . createElement ( 'script' ) ;
168
+ script . setAttribute ( 'nonce' , nonce ) ;
169
+ script . textContent = [
170
+ `(() => {` ,
171
+ // Save the `children` in a variable since they're a live DOM node collection.
172
+ // We iterate over the direct descendants, instead of going through a `querySelectorAll`,
173
+ // because we know that the tags will be directly inside the `head`.
174
+ ` const children = document.head.children;` ,
175
+ // Declare `onLoad` outside the loop to avoid leaking memory.
176
+ // Can't be an arrow function, because we need `this` to refer to the DOM node.
177
+ ` function onLoad() {this.media = this.getAttribute('${ CSP_MEDIA_ATTR } ');}` ,
178
+ // Has to use a plain for loop, because some browsers don't support
179
+ // `forEach` on `children` which is a `HTMLCollection`.
180
+ ` for (let i = 0; i < children.length; i++) {` ,
181
+ ` const child = children[i];` ,
182
+ ` child.hasAttribute('${ CSP_MEDIA_ATTR } ') && child.addEventListener('load', onLoad);` ,
183
+ ` }` ,
184
+ `})();` ,
185
+ ] . join ( '\n' ) ;
186
+ // Append the script to the head since it needs to
187
+ // run as early as possible, after the `link` tags.
188
+ document . head . appendChild ( script ) ;
189
+ this . addedCspScriptsDocuments . add ( document ) ;
190
+ }
55
191
}
56
192
57
193
export class InlineCriticalCssProcessor {
0 commit comments