Skip to content

Commit 9144237

Browse files
authored
Fetch the static head and validate it (#30)
1 parent 2afa695 commit 9144237

File tree

6 files changed

+615
-261
lines changed

6 files changed

+615
-261
lines changed

capo.js

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4253
const 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(/(<\/?)(head)/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+
4489
function isMeta(element) {
4590
return element.matches('meta:is([charset], [http-equiv], [name=viewport]), base');
4691
}
@@ -105,17 +150,17 @@ function getWeight(element) {
105150
}
106151

107152
function 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

114159
function 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+
131211
function 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+
159280
function 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();
173314
validateHead();
174315
logWeights();

crx/capo.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,46 @@ body {
1515
height: 30px;
1616
box-sizing: border-box;
1717
}
18+
.capo-head-element.invalid {
19+
background-image: repeating-linear-gradient(
20+
45deg,
21+
rgba(0, 0, 0, 0),
22+
rgba(0, 0, 0, 0) 3px,
23+
rgba(255, 0, 0, 0.3) 3px,
24+
rgba(255, 0, 0, 0.3) 6px
25+
);
26+
}
27+
.capo-head-element.invalid:is(
28+
[data-weight="10"],
29+
[data-weight="9"],
30+
[data-weight="8"],
31+
[data-weight="7"]
32+
) {
33+
background-image: repeating-linear-gradient(
34+
45deg,
35+
rgba(255, 255, 255, 0.3),
36+
rgba(255, 255, 255, 0.3) 3px,
37+
rgba(0, 0, 0, 0) 3px,
38+
rgba(0, 0, 0, 0) 6px
39+
);
40+
}.capo-head-element.invalid:is(
41+
[data-weight="2"],
42+
[data-weight="1"]
43+
) {
44+
background-image: repeating-linear-gradient(
45+
45deg,
46+
rgba(255, 255, 255, 0.3),
47+
rgba(255, 255, 255, 0.3) 3px,
48+
rgba(255, 0, 0, 0.3) 3px,
49+
rgba(255, 0, 0, 0.3) 6px
50+
);
51+
}
1852
.capo-head-element:hover {
1953
box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0.1);
2054
}
55+
.capo-head-element.invalid:hover {
56+
box-shadow: inset 0 0 0 9999px rgba(255, 0, 0, 0.1);
57+
}
2158

2259
[data-weight="10"] {
2360
background-color: #9e0142;

crx/capo.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
<div id="actual" class="capo-container"></div>
55
<div id="sorted" class="capo-container"></div>
66

7-
<script type="module" src="chrome.js"></script>
7+
<script type="module" src="capo.js"></script>

0 commit comments

Comments
 (0)