@@ -39,8 +39,53 @@ const WEIGHT_COLORS = [
3939 '#cccccc'
4040] ;
4141
42+ const VALID_HEAD_ELEMENTS = new Set ( [
43+ 'base' ,
44+ 'link' ,
45+ 'meta' ,
46+ 'noscript' ,
47+ 'script' ,
48+ 'style' ,
49+ 'template' ,
50+ 'title'
51+ ] ) ;
52+
4253const LOGGING_PREFIX = 'Capo: ' ;
4354
55+ let head ;
56+
57+ let isStaticHead = false ;
58+
59+
60+ async function getStaticHTML ( ) {
61+ const url = document . location . href ;
62+ const response = await fetch ( url ) ;
63+ return await response . text ( ) ;
64+ }
65+
66+ async function getStaticOrDynamicHead ( ) {
67+ if ( head ) {
68+ return head ;
69+ }
70+
71+ try {
72+ let html = await getStaticHTML ( ) ;
73+ html = html . replace ( / ( < \/ ? ) ( h e a d ) / ig, '$1static-head' ) ;
74+ const staticDoc = document . implementation . createHTMLDocument ( 'New Document' ) ;
75+ staticDoc . documentElement . innerHTML = html ;
76+ head = staticDoc . querySelector ( 'static-head' ) ;
77+
78+ if ( head ) {
79+ isStaticHead = true ;
80+ } else {
81+ head = document . head ;
82+ }
83+ } catch {
84+ head = document . head ;
85+ }
86+ return head ;
87+ }
88+
4489function isMeta ( element ) {
4590 return element . matches ( 'meta:is([charset], [http-equiv], [name=viewport]), base' ) ;
4691}
@@ -105,17 +150,17 @@ function getWeight(element) {
105150}
106151
107152function getHeadWeights ( ) {
108- const headChildren = Array . from ( document . head . children ) ;
153+ const headChildren = Array . from ( head . children ) ;
109154 return headChildren . map ( element => {
110- return [ element , getWeight ( element ) ] ;
155+ return [ getLoggableElement ( element ) , getWeight ( element ) , isValidElement ( element ) ] ;
111156 } ) ;
112157}
113158
114159function visualizeWeights ( weights ) {
115160 const visual = weights . map ( _ => '%c ' ) . join ( '' ) ;
116161 const styles = weights . map ( weight => {
117162 const color = WEIGHT_COLORS [ 10 - weight ] ;
118- return `background-color: ${ color } ; padding: 5px; margin: -1px;`
163+ return `background-color: ${ color } ; padding: 5px; margin: 0 -1px;`
119164 } ) ;
120165
121166 return { visual, styles} ;
@@ -128,16 +173,59 @@ function visualizeWeight(weight) {
128173 return { visual, style} ;
129174}
130175
176+ function stringifyElement ( element ) {
177+ return element . getAttributeNames ( ) . reduce ( ( id , attr ) => id += `[${ attr } =${ JSON . stringify ( element . getAttribute ( attr ) ) } ]` , element . nodeName ) ;
178+ }
179+
180+ function getLoggableElement ( element ) {
181+ if ( ! isStaticHead ) {
182+ return element ;
183+ }
184+
185+ const selector = stringifyElement ( element ) ;
186+ const candidates = Array . from ( document . head . querySelectorAll ( selector ) ) ;
187+ if ( candidates . length == 0 ) {
188+ return element ;
189+ }
190+ if ( candidates . length == 1 ) {
191+ return candidates [ 0 ] ;
192+ }
193+
194+ // The way the static elements are parsed makes their innerHTML different.
195+ // Recreate the element in DOM and compare its innerHTML with those of the candidates.
196+ // This ensures a consistent parsing and positive string matches.
197+ const candidateWrapper = document . createElement ( 'div' ) ;
198+ const elementWrapper = document . createElement ( 'div' ) ;
199+ elementWrapper . innerHTML = element . innerHTML ;
200+ const candidate = candidates . find ( c => {
201+ candidateWrapper . innerHTML = c . innerHTML ;
202+ return candidateWrapper . innerHTML == elementWrapper . innerHTML ;
203+ } ) ;
204+ if ( candidate ) {
205+ return candidate ;
206+ }
207+
208+ return element ;
209+ }
210+
131211function logWeights ( ) {
132212 const headWeights = getHeadWeights ( ) ;
133213 const actualViz = visualizeWeights ( headWeights . map ( ( [ _ , weight ] ) => weight ) ) ;
214+
215+ if ( ! isStaticHead ) {
216+ console . warn ( `${ LOGGING_PREFIX } Unable to parse the static (server-rendered) <head>. Falling back to document.head` , document . head ) ;
217+ }
134218
135219 console . groupCollapsed ( `${ LOGGING_PREFIX } Actual %c<head>%c order\n${ actualViz . visual } ` , 'font-family: monospace' , 'font-family: inherit' , ...actualViz . styles ) ;
136- headWeights . forEach ( ( [ element , weight ] ) => {
220+ headWeights . forEach ( ( [ element , weight , isValid ] ) => {
137221 const viz = visualizeWeight ( weight ) ;
138- console . log ( viz . visual , viz . style , weight + 1 , element ) ;
222+ if ( isStaticHead && ! isValid ) {
223+ console . warn ( viz . visual , viz . style , weight + 1 , element , '❌ invalid element' ) ;
224+ } else {
225+ console . log ( viz . visual , viz . style , weight + 1 , element ) ;
226+ }
139227 } ) ;
140- console . log ( 'Actual %c<head>%c element' , 'font-family: monospace' , 'font-family: inherit' , document . head ) ;
228+ console . log ( 'Actual %c<head>%c element' , 'font-family: monospace' , 'font-family: inherit' , head ) ;
141229 console . groupEnd ( ) ;
142230
143231 const sortedWeights = headWeights . sort ( ( a , b ) => {
@@ -147,28 +235,81 @@ function logWeights() {
147235
148236 console . groupCollapsed ( `${ LOGGING_PREFIX } Sorted %c<head>%c order\n${ sortedViz . visual } ` , 'font-family: monospace' , 'font-family: inherit' , ...sortedViz . styles ) ;
149237 const sortedHead = document . createElement ( 'head' ) ;
150- sortedWeights . forEach ( ( [ element , weight ] ) => {
238+ sortedWeights . forEach ( ( [ element , weight , isValid ] ) => {
151239 const viz = visualizeWeight ( weight ) ;
152- console . log ( viz . visual , viz . style , weight + 1 , element ) ;
240+ if ( isStaticHead && ! isValid ) {
241+ console . warn ( viz . visual , viz . style , weight + 1 , element , '❌ invalid element' ) ;
242+ } else {
243+ console . log ( viz . visual , viz . style , weight + 1 , element ) ;
244+ }
153245 sortedHead . appendChild ( element . cloneNode ( true ) ) ;
154246 } ) ;
155247 console . log ( 'Sorted %c<head>%c element' , 'font-family: monospace' , 'font-family: inherit' , sortedHead ) ;
156248 console . groupEnd ( ) ;
157249}
158250
251+ function isValidElement ( element ) {
252+ // Element itself is not valid.
253+ if ( ! VALID_HEAD_ELEMENTS . has ( element . tagName . toLowerCase ( ) ) ) {
254+ return false ;
255+ }
256+
257+ // Children are not valid.
258+ if ( element . matches ( `:has(:not(${ Array . from ( VALID_HEAD_ELEMENTS ) . join ( ', ' ) } ))` ) ) {
259+ return false ;
260+ }
261+
262+ // <title> is not the first of its type.
263+ if ( element . matches ( 'title:is(:nth-of-type(n+2))' ) ) {
264+ return false ;
265+ }
266+
267+ // <base> is not the first of its type.
268+ if ( element . matches ( 'base:is(:nth-of-type(n+2))' ) ) {
269+ return false ;
270+ }
271+
272+ // CSP meta tag comes after a script.
273+ if ( element . matches ( 'script ~ meta[http-equiv="Content-Security-Policy" i]' ) ) {
274+ return false ;
275+ }
276+
277+ return true ;
278+ }
279+
159280function validateHead ( ) {
160- const titleElements = document . querySelectorAll ( 'head title' ) ;
281+ const titleElements = Array . from ( head . querySelectorAll ( 'title' ) ) . map ( getLoggableElement ) ;
161282 const titleElementCount = titleElements . length ;
162283 if ( titleElementCount != 1 ) {
163284 console . warn ( `${ LOGGING_PREFIX } Expected exactly 1 <title> element, found ${ titleElementCount } ` , titleElements ) ;
164285 }
165286
166- const baseElements = document . querySelectorAll ( 'head base' ) ;
287+ const baseElements = Array . from ( head . querySelectorAll ( 'base' ) ) . map ( getLoggableElement ) ;
167288 const baseElementCount = baseElements . length ;
168289 if ( baseElementCount > 1 ) {
169290 console . warn ( `${ LOGGING_PREFIX } Expected at most 1 <base> element, found ${ baseElementCount } ` , baseElements ) ;
170291 }
292+
293+ const postScriptCSP = head . querySelector ( 'script ~ meta[http-equiv="Content-Security-Policy" i]' ) ;
294+ if ( postScriptCSP ) {
295+ console . warn ( `${ LOGGING_PREFIX } CSP meta tag must be placed before any <script> elements to avoid disabling the preload scanner.` , getLoggableElement ( postScriptCSP ) ) ;
296+ }
297+
298+ if ( ! isStaticHead ) {
299+ return ;
300+ }
301+ head . querySelectorAll ( '*' ) . forEach ( element => {
302+ if ( VALID_HEAD_ELEMENTS . has ( element . tagName . toLowerCase ( ) ) ) {
303+ return ;
304+ }
305+ let root = element ;
306+ while ( root . parentElement != head ) {
307+ root = root . parentElement ;
308+ }
309+ console . warn ( `${ LOGGING_PREFIX } ${ element . tagName } elements are not allowed in the <head>` , getLoggableElement ( root ) ) ;
310+ } ) ;
171311}
172312
313+ await getStaticOrDynamicHead ( ) ;
173314validateHead ( ) ;
174315logWeights ( ) ;
0 commit comments