-
Notifications
You must be signed in to change notification settings - Fork 4.3k
/
Copy pathhead.ts
123 lines (107 loc) · 3.15 KB
/
head.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
/**
* The cache of prefetched stylesheets and scripts.
*/
export const headElements = new Map<
string,
{ tag: HTMLElement; text?: string }
>();
/**
* Helper to update only the necessary tags in the head.
*
* @async
* @param newHead The head elements of the new page.
*/
export const updateHead = async ( newHead: HTMLHeadElement[] ) => {
// Helper to get the tag id store in the cache.
const getTagId = ( tag: Element ) => tag.id || tag.outerHTML;
// Map incoming head tags by their content.
const newHeadMap = new Map< string, Element >();
for ( const child of newHead ) {
newHeadMap.set( getTagId( child ), child );
}
const toRemove: Element[] = [];
// Detect nodes that should be added or removed.
for ( const child of document.head.children ) {
const id = getTagId( child );
// Always remove styles and links as they might change.
if ( child.nodeName === 'LINK' || child.nodeName === 'STYLE' ) {
toRemove.push( child );
} else if ( newHeadMap.has( id ) ) {
newHeadMap.delete( id );
} else if ( child.nodeName !== 'SCRIPT' && child.nodeName !== 'META' ) {
toRemove.push( child );
}
}
await Promise.all(
[ ...headElements.entries() ]
.filter( ( [ , { tag } ] ) => tag.nodeName === 'SCRIPT' )
.map( async ( [ url ] ) => {
await import( /* webpackIgnore: true */ url );
} )
);
// Prepare new assets.
const toAppend = [ ...newHeadMap.values() ];
// Apply the changes.
toRemove.forEach( ( n ) => n.remove() );
document.head.append( ...toAppend );
};
/**
* Fetches and processes head assets (stylesheets and scripts) from a specified document.
*
* @async
* @param doc The document from which to fetch head assets. It should support standard DOM querying methods.
*
* @return Returns an array of HTML elements representing the head assets.
*/
export const fetchHeadAssets = async (
doc: Document
): Promise< HTMLElement[] > => {
const headTags = [];
const scripts = doc.querySelectorAll< HTMLScriptElement >(
'script[type="module"][src]'
);
scripts.forEach( ( script ) => {
const src = script.getAttribute( 'src' );
if ( ! headElements.has( src ) ) {
// add the <link> elements to prefetch the module scripts
const link = doc.createElement( 'link' );
link.rel = 'modulepreload';
link.href = src;
document.head.append( link );
headElements.set( src, { tag: script } );
}
} );
const stylesheets = doc.querySelectorAll< HTMLLinkElement >(
'link[rel=stylesheet]'
);
await Promise.all(
Array.from( stylesheets ).map( async ( tag ) => {
const href = tag.getAttribute( 'href' );
if ( ! href ) {
return;
}
if ( ! headElements.has( href ) ) {
try {
const response = await fetch( href );
const text = await response.text();
headElements.set( href, {
tag,
text,
} );
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( e );
}
}
const headElement = headElements.get( href );
const styleElement = doc.createElement( 'style' );
styleElement.textContent = headElement.text;
headTags.push( styleElement );
} )
);
return [
doc.querySelector( 'title' ),
...doc.querySelectorAll( 'style' ),
...headTags,
];
};