-
Notifications
You must be signed in to change notification settings - Fork 12k
/
Copy pathinline-critical-css.ts
248 lines (215 loc) · 8.78 KB
/
inline-critical-css.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
/**
* @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 Critters from 'critters';
import { readFile } from 'node:fs/promises';
/**
* Pattern used to extract the media query set by Critters in an `onload` handler.
*/
const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/;
/**
* Name of the attribute used to save the Critters media query so it can be re-assigned on load.
*/
const CSP_MEDIA_ATTR = 'ngCspMedia';
/**
* Script text used to change the media value of the link tags.
*
* NOTE:
* We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)`
* because this does not always fire on Chome.
* See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256
*/
const LINK_LOAD_SCRIPT_CONTENT = [
'(() => {',
` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`,
' const documentElement = document.documentElement;',
' const listener = (e) => {',
' const target = e.target;',
` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`,
' return;',
' }',
' target.media = target.getAttribute(CSP_MEDIA_ATTR);',
' target.removeAttribute(CSP_MEDIA_ATTR);',
// Remove onload listener when there are no longer styles that need to be loaded.
' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {',
` documentElement.removeEventListener('load', listener);`,
' }',
' };',
// We use an event with capturing (the true parameter) because load events don't bubble.
` documentElement.addEventListener('load', listener, true);`,
'})();',
].join('\n');
export interface InlineCriticalCssProcessOptions {
outputPath: string;
}
export interface InlineCriticalCssProcessorOptions {
minify?: boolean;
deployUrl?: string;
readAsset?: (path: string) => Promise<string>;
}
/** Partial representation of an `HTMLElement`. */
interface PartialHTMLElement {
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
hasAttribute(name: string): boolean;
removeAttribute(name: string): void;
appendChild(child: PartialHTMLElement): void;
insertBefore(newNode: PartialHTMLElement, referenceNode?: PartialHTMLElement): void;
remove(): void;
name: string;
textContent: string;
tagName: string | null;
children: PartialHTMLElement[];
next: PartialHTMLElement | null;
prev: PartialHTMLElement | null;
}
/** Partial representation of an HTML `Document`. */
interface PartialDocument {
head: PartialHTMLElement;
createElement(tagName: string): PartialHTMLElement;
querySelector(selector: string): PartialHTMLElement | null;
}
/** Signature of the `Critters.embedLinkedStylesheet` method. */
type EmbedLinkedStylesheetFn = (
link: PartialHTMLElement,
document: PartialDocument,
) => Promise<unknown>;
class CrittersExtended extends Critters {
readonly warnings: string[] = [];
readonly errors: string[] = [];
private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn;
private addedCspScriptsDocuments = new WeakSet<PartialDocument>();
private documentNonces = new WeakMap<PartialDocument, string | null>();
// Inherited from `Critters`, but not exposed in the typings.
protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn;
constructor(
private readonly optionsExtended: InlineCriticalCssProcessorOptions &
InlineCriticalCssProcessOptions,
) {
super({
logger: {
warn: (s: string) => this.warnings.push(s),
error: (s: string) => this.errors.push(s),
info: () => {},
},
logLevel: 'warn',
path: optionsExtended.outputPath,
publicPath: optionsExtended.deployUrl,
compress: !!optionsExtended.minify,
pruneSource: false,
reduceInlineStyles: false,
mergeStylesheets: false,
// Note: if `preload` changes to anything other than `media`, the logic in
// `embedLinkedStylesheetOverride` will have to be updated.
preload: 'media',
noscriptFallback: true,
inlineFonts: true,
});
// We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in
// the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't
// allow for `super` to be cast to a different type.
this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet;
this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride;
}
public override readFile(path: string): Promise<string> {
const readAsset = this.optionsExtended.readAsset;
return readAsset ? readAsset(path) : readFile(path, 'utf-8');
}
/**
* Override of the Critters `embedLinkedStylesheet` method
* that makes it work with Angular's CSP APIs.
*/
private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => {
if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') {
// Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
// NB: this is only needed for the webpack based builders.
const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
if (media) {
link.removeAttribute('onload');
link.setAttribute('media', media[1]);
link?.next?.remove();
}
}
const returnValue = await this.initialEmbedLinkedStylesheet(link, document);
const cspNonce = this.findCspNonce(document);
if (cspNonce) {
const crittersMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);
if (crittersMedia) {
// If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce,
// we have to remove the handler, because it's incompatible with CSP. We save the value
// in a different attribute and we generate a script tag with the nonce that uses
// `addEventListener` to apply the media query instead.
link.removeAttribute('onload');
link.setAttribute(CSP_MEDIA_ATTR, crittersMedia[1]);
this.conditionallyInsertCspLoadingScript(document, cspNonce, link);
}
// Ideally we would hook in at the time Critters inserts the `style` tags, but there isn't
// a way of doing that at the moment so we fall back to doing it any time a `link` tag is
// inserted. We mitigate it by only iterating the direct children of the `<head>` which
// should be pretty shallow.
document.head.children.forEach((child) => {
if (child.tagName === 'style' && !child.hasAttribute('nonce')) {
child.setAttribute('nonce', cspNonce);
}
});
}
return returnValue;
};
/**
* Finds the CSP nonce for a specific document.
*/
private findCspNonce(document: PartialDocument): string | null {
if (this.documentNonces.has(document)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.documentNonces.get(document)!;
}
// HTML attribute are case-insensitive, but the parser used by Critters is case-sensitive.
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
const cspNonce =
nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null;
this.documentNonces.set(document, cspNonce);
return cspNonce;
}
/**
* Inserts the `script` tag that swaps the critical CSS at runtime,
* if one hasn't been inserted into the document already.
*/
private conditionallyInsertCspLoadingScript(
document: PartialDocument,
nonce: string,
link: PartialHTMLElement,
): void {
if (this.addedCspScriptsDocuments.has(document)) {
return;
}
const script = document.createElement('script');
script.setAttribute('nonce', nonce);
script.textContent = LINK_LOAD_SCRIPT_CONTENT;
// Prepend the script to the head since it needs to
// run as early as possible, before the `link` tags.
document.head.insertBefore(script, link);
this.addedCspScriptsDocuments.add(document);
}
}
export class InlineCriticalCssProcessor {
constructor(protected readonly options: InlineCriticalCssProcessorOptions) {}
async process(
html: string,
options: InlineCriticalCssProcessOptions,
): Promise<{ content: string; warnings: string[]; errors: string[] }> {
const critters = new CrittersExtended({ ...this.options, ...options });
const content = await critters.process(html);
return {
// Clean up value from value less attributes.
// This is caused because parse5 always requires attributes to have a string value.
// nomodule="" defer="" -> nomodule defer.
content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'),
errors: critters.errors,
warnings: critters.warnings,
};
}
}