Comparing version 0.0.20 to 0.0.21
1124
dist/critters.js
@@ -11,2 +11,3 @@ function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } | ||
var postcss = require('postcss'); | ||
var mediaParser = _interopDefault(require('postcss-media-query-parser')); | ||
var chalk = _interopDefault(require('chalk')); | ||
@@ -16,25 +17,25 @@ | ||
(function (SelectorType) { | ||
SelectorType["Attribute"] = "attribute"; | ||
SelectorType["Pseudo"] = "pseudo"; | ||
SelectorType["PseudoElement"] = "pseudo-element"; | ||
SelectorType["Tag"] = "tag"; | ||
SelectorType["Universal"] = "universal"; | ||
// Traversals | ||
SelectorType["Adjacent"] = "adjacent"; | ||
SelectorType["Child"] = "child"; | ||
SelectorType["Descendant"] = "descendant"; | ||
SelectorType["Parent"] = "parent"; | ||
SelectorType["Sibling"] = "sibling"; | ||
SelectorType["ColumnCombinator"] = "column-combinator"; | ||
SelectorType["Attribute"] = "attribute"; | ||
SelectorType["Pseudo"] = "pseudo"; | ||
SelectorType["PseudoElement"] = "pseudo-element"; | ||
SelectorType["Tag"] = "tag"; | ||
SelectorType["Universal"] = "universal"; | ||
// Traversals | ||
SelectorType["Adjacent"] = "adjacent"; | ||
SelectorType["Child"] = "child"; | ||
SelectorType["Descendant"] = "descendant"; | ||
SelectorType["Parent"] = "parent"; | ||
SelectorType["Sibling"] = "sibling"; | ||
SelectorType["ColumnCombinator"] = "column-combinator"; | ||
})(SelectorType || (SelectorType = {})); | ||
var AttributeAction; | ||
(function (AttributeAction) { | ||
AttributeAction["Any"] = "any"; | ||
AttributeAction["Element"] = "element"; | ||
AttributeAction["End"] = "end"; | ||
AttributeAction["Equals"] = "equals"; | ||
AttributeAction["Exists"] = "exists"; | ||
AttributeAction["Hyphen"] = "hyphen"; | ||
AttributeAction["Not"] = "not"; | ||
AttributeAction["Start"] = "start"; | ||
AttributeAction["Any"] = "any"; | ||
AttributeAction["Element"] = "element"; | ||
AttributeAction["End"] = "end"; | ||
AttributeAction["Equals"] = "equals"; | ||
AttributeAction["Exists"] = "exists"; | ||
AttributeAction["Hyphen"] = "hyphen"; | ||
AttributeAction["Not"] = "not"; | ||
AttributeAction["Start"] = "start"; | ||
})(AttributeAction || (AttributeAction = {})); | ||
@@ -44,20 +45,5 @@ | ||
const reEscape = /\\([\da-f]{1,6}\s?|(\s)|.)/gi; | ||
const actionTypes = new Map([ | ||
[126 /* Tilde */, AttributeAction.Element], | ||
[94 /* Circumflex */, AttributeAction.Start], | ||
[36 /* Dollar */, AttributeAction.End], | ||
[42 /* Asterisk */, AttributeAction.Any], | ||
[33 /* ExclamationMark */, AttributeAction.Not], | ||
[124 /* Pipe */, AttributeAction.Hyphen], | ||
]); | ||
const actionTypes = new Map([[126 /* Tilde */, AttributeAction.Element], [94 /* Circumflex */, AttributeAction.Start], [36 /* Dollar */, AttributeAction.End], [42 /* Asterisk */, AttributeAction.Any], [33 /* ExclamationMark */, AttributeAction.Not], [124 /* Pipe */, AttributeAction.Hyphen]]); | ||
// Pseudos, whose data property is parsed as well. | ||
const unpackPseudos = new Set([ | ||
"has", | ||
"not", | ||
"matches", | ||
"is", | ||
"where", | ||
"host", | ||
"host-context", | ||
]); | ||
const unpackPseudos = new Set(["has", "not", "matches", "is", "where", "host", "host-context"]); | ||
/** | ||
@@ -71,13 +57,13 @@ * Checks whether a specific selector is a traversal. | ||
function isTraversal(selector) { | ||
switch (selector.type) { | ||
case SelectorType.Adjacent: | ||
case SelectorType.Child: | ||
case SelectorType.Descendant: | ||
case SelectorType.Parent: | ||
case SelectorType.Sibling: | ||
case SelectorType.ColumnCombinator: | ||
return true; | ||
default: | ||
return false; | ||
} | ||
switch (selector.type) { | ||
case SelectorType.Adjacent: | ||
case SelectorType.Child: | ||
case SelectorType.Descendant: | ||
case SelectorType.Parent: | ||
case SelectorType.Sibling: | ||
case SelectorType.ColumnCombinator: | ||
return true; | ||
default: | ||
return false; | ||
} | ||
} | ||
@@ -87,24 +73,18 @@ const stripQuotesFromPseudos = new Set(["contains", "icontains"]); | ||
function funescape(_, escaped, escapedWhitespace) { | ||
const high = parseInt(escaped, 16) - 0x10000; | ||
// NaN means non-codepoint | ||
return high !== high || escapedWhitespace | ||
? escaped | ||
: high < 0 | ||
? // BMP codepoint | ||
String.fromCharCode(high + 0x10000) | ||
: // Supplemental Plane codepoint (surrogate pair) | ||
String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00); | ||
const high = parseInt(escaped, 16) - 0x10000; | ||
// NaN means non-codepoint | ||
return high !== high || escapedWhitespace ? escaped : high < 0 ? | ||
// BMP codepoint | ||
String.fromCharCode(high + 0x10000) : | ||
// Supplemental Plane codepoint (surrogate pair) | ||
String.fromCharCode(high >> 10 | 0xd800, high & 0x3ff | 0xdc00); | ||
} | ||
function unescapeCSS(str) { | ||
return str.replace(reEscape, funescape); | ||
return str.replace(reEscape, funescape); | ||
} | ||
function isQuote(c) { | ||
return c === 39 /* SingleQuote */ || c === 34 /* DoubleQuote */; | ||
return c === 39 /* SingleQuote */ || c === 34 /* DoubleQuote */; | ||
} | ||
function isWhitespace(c) { | ||
return (c === 32 /* Space */ || | ||
c === 9 /* Tab */ || | ||
c === 10 /* NewLine */ || | ||
c === 12 /* FormFeed */ || | ||
c === 13 /* CarriageReturn */); | ||
return c === 32 /* Space */ || c === 9 /* Tab */ || c === 10 /* NewLine */ || c === 12 /* FormFeed */ || c === 13 /* CarriageReturn */; | ||
} | ||
@@ -121,344 +101,330 @@ /** | ||
function parse(selector) { | ||
const subselects = []; | ||
const endIndex = parseSelector(subselects, `${selector}`, 0); | ||
if (endIndex < selector.length) { | ||
throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`); | ||
} | ||
return subselects; | ||
const subselects = []; | ||
const endIndex = parseSelector(subselects, `${selector}`, 0); | ||
if (endIndex < selector.length) { | ||
throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`); | ||
} | ||
return subselects; | ||
} | ||
function parseSelector(subselects, selector, selectorIndex) { | ||
let tokens = []; | ||
function getName(offset) { | ||
const match = selector.slice(selectorIndex + offset).match(reName); | ||
if (!match) { | ||
throw new Error(`Expected name, found ${selector.slice(selectorIndex)}`); | ||
} | ||
const [name] = match; | ||
selectorIndex += offset + name.length; | ||
return unescapeCSS(name); | ||
let tokens = []; | ||
function getName(offset) { | ||
const match = selector.slice(selectorIndex + offset).match(reName); | ||
if (!match) { | ||
throw new Error(`Expected name, found ${selector.slice(selectorIndex)}`); | ||
} | ||
function stripWhitespace(offset) { | ||
selectorIndex += offset; | ||
while (selectorIndex < selector.length && | ||
isWhitespace(selector.charCodeAt(selectorIndex))) { | ||
selectorIndex++; | ||
} | ||
const [name] = match; | ||
selectorIndex += offset + name.length; | ||
return unescapeCSS(name); | ||
} | ||
function stripWhitespace(offset) { | ||
selectorIndex += offset; | ||
while (selectorIndex < selector.length && isWhitespace(selector.charCodeAt(selectorIndex))) { | ||
selectorIndex++; | ||
} | ||
function readValueWithParenthesis() { | ||
selectorIndex += 1; | ||
const start = selectorIndex; | ||
let counter = 1; | ||
for (; counter > 0 && selectorIndex < selector.length; selectorIndex++) { | ||
if (selector.charCodeAt(selectorIndex) === | ||
40 /* LeftParenthesis */ && | ||
!isEscaped(selectorIndex)) { | ||
counter++; | ||
} | ||
else if (selector.charCodeAt(selectorIndex) === | ||
41 /* RightParenthesis */ && | ||
!isEscaped(selectorIndex)) { | ||
counter--; | ||
} | ||
} | ||
if (counter) { | ||
throw new Error("Parenthesis not matched"); | ||
} | ||
return unescapeCSS(selector.slice(start, selectorIndex - 1)); | ||
} | ||
function readValueWithParenthesis() { | ||
selectorIndex += 1; | ||
const start = selectorIndex; | ||
let counter = 1; | ||
for (; counter > 0 && selectorIndex < selector.length; selectorIndex++) { | ||
if (selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */ && !isEscaped(selectorIndex)) { | ||
counter++; | ||
} else if (selector.charCodeAt(selectorIndex) === 41 /* RightParenthesis */ && !isEscaped(selectorIndex)) { | ||
counter--; | ||
} | ||
} | ||
function isEscaped(pos) { | ||
let slashCount = 0; | ||
while (selector.charCodeAt(--pos) === 92 /* BackSlash */) | ||
slashCount++; | ||
return (slashCount & 1) === 1; | ||
if (counter) { | ||
throw new Error("Parenthesis not matched"); | ||
} | ||
function ensureNotTraversal() { | ||
if (tokens.length > 0 && isTraversal(tokens[tokens.length - 1])) { | ||
throw new Error("Did not expect successive traversals."); | ||
} | ||
return unescapeCSS(selector.slice(start, selectorIndex - 1)); | ||
} | ||
function isEscaped(pos) { | ||
let slashCount = 0; | ||
while (selector.charCodeAt(--pos) === 92 /* BackSlash */) slashCount++; | ||
return (slashCount & 1) === 1; | ||
} | ||
function ensureNotTraversal() { | ||
if (tokens.length > 0 && isTraversal(tokens[tokens.length - 1])) { | ||
throw new Error("Did not expect successive traversals."); | ||
} | ||
function addTraversal(type) { | ||
if (tokens.length > 0 && | ||
tokens[tokens.length - 1].type === SelectorType.Descendant) { | ||
tokens[tokens.length - 1].type = type; | ||
return; | ||
} | ||
ensureNotTraversal(); | ||
tokens.push({ type }); | ||
} | ||
function addTraversal(type) { | ||
if (tokens.length > 0 && tokens[tokens.length - 1].type === SelectorType.Descendant) { | ||
tokens[tokens.length - 1].type = type; | ||
return; | ||
} | ||
function addSpecialAttribute(name, action) { | ||
tokens.push({ | ||
type: SelectorType.Attribute, | ||
name, | ||
action, | ||
value: getName(1), | ||
namespace: null, | ||
ignoreCase: "quirks", | ||
}); | ||
ensureNotTraversal(); | ||
tokens.push({ | ||
type | ||
}); | ||
} | ||
function addSpecialAttribute(name, action) { | ||
tokens.push({ | ||
type: SelectorType.Attribute, | ||
name, | ||
action, | ||
value: getName(1), | ||
namespace: null, | ||
ignoreCase: "quirks" | ||
}); | ||
} | ||
/** | ||
* We have finished parsing the current part of the selector. | ||
* | ||
* Remove descendant tokens at the end if they exist, | ||
* and return the last index, so that parsing can be | ||
* picked up from here. | ||
*/ | ||
function finalizeSubselector() { | ||
if (tokens.length && tokens[tokens.length - 1].type === SelectorType.Descendant) { | ||
tokens.pop(); | ||
} | ||
/** | ||
* We have finished parsing the current part of the selector. | ||
* | ||
* Remove descendant tokens at the end if they exist, | ||
* and return the last index, so that parsing can be | ||
* picked up from here. | ||
*/ | ||
function finalizeSubselector() { | ||
if (tokens.length && | ||
tokens[tokens.length - 1].type === SelectorType.Descendant) { | ||
tokens.pop(); | ||
if (tokens.length === 0) { | ||
throw new Error("Empty sub-selector"); | ||
} | ||
subselects.push(tokens); | ||
} | ||
stripWhitespace(0); | ||
if (selector.length === selectorIndex) { | ||
return selectorIndex; | ||
} | ||
loop: while (selectorIndex < selector.length) { | ||
const firstChar = selector.charCodeAt(selectorIndex); | ||
switch (firstChar) { | ||
// Whitespace | ||
case 32 /* Space */: | ||
case 9 /* Tab */: | ||
case 10 /* NewLine */: | ||
case 12 /* FormFeed */: | ||
case 13 /* CarriageReturn */: | ||
{ | ||
if (tokens.length === 0 || tokens[0].type !== SelectorType.Descendant) { | ||
ensureNotTraversal(); | ||
tokens.push({ | ||
type: SelectorType.Descendant | ||
}); | ||
} | ||
stripWhitespace(1); | ||
break; | ||
} | ||
if (tokens.length === 0) { | ||
throw new Error("Empty sub-selector"); | ||
// Traversals | ||
case 62 /* GreaterThan */: | ||
{ | ||
addTraversal(SelectorType.Child); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
subselects.push(tokens); | ||
} | ||
stripWhitespace(0); | ||
if (selector.length === selectorIndex) { | ||
return selectorIndex; | ||
} | ||
loop: while (selectorIndex < selector.length) { | ||
const firstChar = selector.charCodeAt(selectorIndex); | ||
switch (firstChar) { | ||
// Whitespace | ||
case 32 /* Space */: | ||
case 9 /* Tab */: | ||
case 10 /* NewLine */: | ||
case 12 /* FormFeed */: | ||
case 13 /* CarriageReturn */: { | ||
if (tokens.length === 0 || | ||
tokens[0].type !== SelectorType.Descendant) { | ||
ensureNotTraversal(); | ||
tokens.push({ type: SelectorType.Descendant }); | ||
} | ||
stripWhitespace(1); | ||
break; | ||
case 60 /* LessThan */: | ||
{ | ||
addTraversal(SelectorType.Parent); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
case 126 /* Tilde */: | ||
{ | ||
addTraversal(SelectorType.Sibling); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
case 43 /* Plus */: | ||
{ | ||
addTraversal(SelectorType.Adjacent); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
// Special attribute selectors: .class, #id | ||
case 46 /* Period */: | ||
{ | ||
addSpecialAttribute("class", AttributeAction.Element); | ||
break; | ||
} | ||
case 35 /* Hash */: | ||
{ | ||
addSpecialAttribute("id", AttributeAction.Equals); | ||
break; | ||
} | ||
case 91 /* LeftSquareBracket */: | ||
{ | ||
stripWhitespace(1); | ||
// Determine attribute name and namespace | ||
let name; | ||
let namespace = null; | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */) { | ||
// Equivalent to no namespace | ||
name = getName(1); | ||
} else if (selector.startsWith("*|", selectorIndex)) { | ||
namespace = "*"; | ||
name = getName(2); | ||
} else { | ||
name = getName(0); | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 61 /* Equal */) { | ||
namespace = name; | ||
name = getName(1); | ||
} | ||
// Traversals | ||
case 62 /* GreaterThan */: { | ||
addTraversal(SelectorType.Child); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
stripWhitespace(0); | ||
// Determine comparison operation | ||
let action = AttributeAction.Exists; | ||
const possibleAction = actionTypes.get(selector.charCodeAt(selectorIndex)); | ||
if (possibleAction) { | ||
action = possibleAction; | ||
if (selector.charCodeAt(selectorIndex + 1) !== 61 /* Equal */) { | ||
throw new Error("Expected `=`"); | ||
} | ||
case 60 /* LessThan */: { | ||
addTraversal(SelectorType.Parent); | ||
stripWhitespace(1); | ||
break; | ||
stripWhitespace(2); | ||
} else if (selector.charCodeAt(selectorIndex) === 61 /* Equal */) { | ||
action = AttributeAction.Equals; | ||
stripWhitespace(1); | ||
} | ||
// Determine value | ||
let value = ""; | ||
let ignoreCase = null; | ||
if (action !== "exists") { | ||
if (isQuote(selector.charCodeAt(selectorIndex))) { | ||
const quote = selector.charCodeAt(selectorIndex); | ||
let sectionEnd = selectorIndex + 1; | ||
while (sectionEnd < selector.length && (selector.charCodeAt(sectionEnd) !== quote || isEscaped(sectionEnd))) { | ||
sectionEnd += 1; | ||
} | ||
if (selector.charCodeAt(sectionEnd) !== quote) { | ||
throw new Error("Attribute value didn't end"); | ||
} | ||
value = unescapeCSS(selector.slice(selectorIndex + 1, sectionEnd)); | ||
selectorIndex = sectionEnd + 1; | ||
} else { | ||
const valueStart = selectorIndex; | ||
while (selectorIndex < selector.length && (!isWhitespace(selector.charCodeAt(selectorIndex)) && selector.charCodeAt(selectorIndex) !== 93 /* RightSquareBracket */ || isEscaped(selectorIndex))) { | ||
selectorIndex += 1; | ||
} | ||
value = unescapeCSS(selector.slice(valueStart, selectorIndex)); | ||
} | ||
case 126 /* Tilde */: { | ||
addTraversal(SelectorType.Sibling); | ||
stripWhitespace(1); | ||
break; | ||
stripWhitespace(0); | ||
// See if we have a force ignore flag | ||
const forceIgnore = selector.charCodeAt(selectorIndex) | 0x20; | ||
// If the forceIgnore flag is set (either `i` or `s`), use that value | ||
if (forceIgnore === 115 /* LowerS */) { | ||
ignoreCase = false; | ||
stripWhitespace(1); | ||
} else if (forceIgnore === 105 /* LowerI */) { | ||
ignoreCase = true; | ||
stripWhitespace(1); | ||
} | ||
case 43 /* Plus */: { | ||
addTraversal(SelectorType.Adjacent); | ||
stripWhitespace(1); | ||
break; | ||
} | ||
if (selector.charCodeAt(selectorIndex) !== 93 /* RightSquareBracket */) { | ||
throw new Error("Attribute selector didn't terminate"); | ||
} | ||
selectorIndex += 1; | ||
const attributeSelector = { | ||
type: SelectorType.Attribute, | ||
name, | ||
action, | ||
value, | ||
namespace, | ||
ignoreCase | ||
}; | ||
tokens.push(attributeSelector); | ||
break; | ||
} | ||
case 58 /* Colon */: | ||
{ | ||
if (selector.charCodeAt(selectorIndex + 1) === 58 /* Colon */) { | ||
tokens.push({ | ||
type: SelectorType.PseudoElement, | ||
name: getName(2).toLowerCase(), | ||
data: selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */ ? readValueWithParenthesis() : null | ||
}); | ||
continue; | ||
} | ||
const name = getName(1).toLowerCase(); | ||
let data = null; | ||
if (selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */) { | ||
if (unpackPseudos.has(name)) { | ||
if (isQuote(selector.charCodeAt(selectorIndex + 1))) { | ||
throw new Error(`Pseudo-selector ${name} cannot be quoted`); | ||
} | ||
data = []; | ||
selectorIndex = parseSelector(data, selector, selectorIndex + 1); | ||
if (selector.charCodeAt(selectorIndex) !== 41 /* RightParenthesis */) { | ||
throw new Error(`Missing closing parenthesis in :${name} (${selector})`); | ||
} | ||
selectorIndex += 1; | ||
} else { | ||
data = readValueWithParenthesis(); | ||
if (stripQuotesFromPseudos.has(name)) { | ||
const quot = data.charCodeAt(0); | ||
if (quot === data.charCodeAt(data.length - 1) && isQuote(quot)) { | ||
data = data.slice(1, -1); | ||
} | ||
} | ||
data = unescapeCSS(data); | ||
} | ||
// Special attribute selectors: .class, #id | ||
case 46 /* Period */: { | ||
addSpecialAttribute("class", AttributeAction.Element); | ||
break; | ||
} | ||
tokens.push({ | ||
type: SelectorType.Pseudo, | ||
name, | ||
data | ||
}); | ||
break; | ||
} | ||
case 44 /* Comma */: | ||
{ | ||
finalizeSubselector(); | ||
tokens = []; | ||
stripWhitespace(1); | ||
break; | ||
} | ||
default: | ||
{ | ||
if (selector.startsWith("/*", selectorIndex)) { | ||
const endIndex = selector.indexOf("*/", selectorIndex + 2); | ||
if (endIndex < 0) { | ||
throw new Error("Comment was not terminated"); | ||
} | ||
case 35 /* Hash */: { | ||
addSpecialAttribute("id", AttributeAction.Equals); | ||
break; | ||
selectorIndex = endIndex + 2; | ||
// Remove leading whitespace | ||
if (tokens.length === 0) { | ||
stripWhitespace(0); | ||
} | ||
case 91 /* LeftSquareBracket */: { | ||
stripWhitespace(1); | ||
// Determine attribute name and namespace | ||
let name; | ||
let namespace = null; | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */) { | ||
// Equivalent to no namespace | ||
name = getName(1); | ||
} | ||
else if (selector.startsWith("*|", selectorIndex)) { | ||
namespace = "*"; | ||
name = getName(2); | ||
} | ||
else { | ||
name = getName(0); | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && | ||
selector.charCodeAt(selectorIndex + 1) !== | ||
61 /* Equal */) { | ||
namespace = name; | ||
name = getName(1); | ||
} | ||
} | ||
stripWhitespace(0); | ||
// Determine comparison operation | ||
let action = AttributeAction.Exists; | ||
const possibleAction = actionTypes.get(selector.charCodeAt(selectorIndex)); | ||
if (possibleAction) { | ||
action = possibleAction; | ||
if (selector.charCodeAt(selectorIndex + 1) !== | ||
61 /* Equal */) { | ||
throw new Error("Expected `=`"); | ||
} | ||
stripWhitespace(2); | ||
} | ||
else if (selector.charCodeAt(selectorIndex) === 61 /* Equal */) { | ||
action = AttributeAction.Equals; | ||
stripWhitespace(1); | ||
} | ||
// Determine value | ||
let value = ""; | ||
let ignoreCase = null; | ||
if (action !== "exists") { | ||
if (isQuote(selector.charCodeAt(selectorIndex))) { | ||
const quote = selector.charCodeAt(selectorIndex); | ||
let sectionEnd = selectorIndex + 1; | ||
while (sectionEnd < selector.length && | ||
(selector.charCodeAt(sectionEnd) !== quote || | ||
isEscaped(sectionEnd))) { | ||
sectionEnd += 1; | ||
} | ||
if (selector.charCodeAt(sectionEnd) !== quote) { | ||
throw new Error("Attribute value didn't end"); | ||
} | ||
value = unescapeCSS(selector.slice(selectorIndex + 1, sectionEnd)); | ||
selectorIndex = sectionEnd + 1; | ||
} | ||
else { | ||
const valueStart = selectorIndex; | ||
while (selectorIndex < selector.length && | ||
((!isWhitespace(selector.charCodeAt(selectorIndex)) && | ||
selector.charCodeAt(selectorIndex) !== | ||
93 /* RightSquareBracket */) || | ||
isEscaped(selectorIndex))) { | ||
selectorIndex += 1; | ||
} | ||
value = unescapeCSS(selector.slice(valueStart, selectorIndex)); | ||
} | ||
stripWhitespace(0); | ||
// See if we have a force ignore flag | ||
const forceIgnore = selector.charCodeAt(selectorIndex) | 0x20; | ||
// If the forceIgnore flag is set (either `i` or `s`), use that value | ||
if (forceIgnore === 115 /* LowerS */) { | ||
ignoreCase = false; | ||
stripWhitespace(1); | ||
} | ||
else if (forceIgnore === 105 /* LowerI */) { | ||
ignoreCase = true; | ||
stripWhitespace(1); | ||
} | ||
} | ||
if (selector.charCodeAt(selectorIndex) !== | ||
93 /* RightSquareBracket */) { | ||
throw new Error("Attribute selector didn't terminate"); | ||
} | ||
selectorIndex += 1; | ||
const attributeSelector = { | ||
type: SelectorType.Attribute, | ||
name, | ||
action, | ||
value, | ||
namespace, | ||
ignoreCase, | ||
}; | ||
tokens.push(attributeSelector); | ||
break; | ||
break; | ||
} | ||
let namespace = null; | ||
let name; | ||
if (firstChar === 42 /* Asterisk */) { | ||
selectorIndex += 1; | ||
name = "*"; | ||
} else if (firstChar === 124 /* Pipe */) { | ||
name = ""; | ||
if (selector.charCodeAt(selectorIndex + 1) === 124 /* Pipe */) { | ||
addTraversal(SelectorType.ColumnCombinator); | ||
stripWhitespace(2); | ||
break; | ||
} | ||
case 58 /* Colon */: { | ||
if (selector.charCodeAt(selectorIndex + 1) === 58 /* Colon */) { | ||
tokens.push({ | ||
type: SelectorType.PseudoElement, | ||
name: getName(2).toLowerCase(), | ||
data: selector.charCodeAt(selectorIndex) === | ||
40 /* LeftParenthesis */ | ||
? readValueWithParenthesis() | ||
: null, | ||
}); | ||
continue; | ||
} | ||
const name = getName(1).toLowerCase(); | ||
let data = null; | ||
if (selector.charCodeAt(selectorIndex) === | ||
40 /* LeftParenthesis */) { | ||
if (unpackPseudos.has(name)) { | ||
if (isQuote(selector.charCodeAt(selectorIndex + 1))) { | ||
throw new Error(`Pseudo-selector ${name} cannot be quoted`); | ||
} | ||
data = []; | ||
selectorIndex = parseSelector(data, selector, selectorIndex + 1); | ||
if (selector.charCodeAt(selectorIndex) !== | ||
41 /* RightParenthesis */) { | ||
throw new Error(`Missing closing parenthesis in :${name} (${selector})`); | ||
} | ||
selectorIndex += 1; | ||
} | ||
else { | ||
data = readValueWithParenthesis(); | ||
if (stripQuotesFromPseudos.has(name)) { | ||
const quot = data.charCodeAt(0); | ||
if (quot === data.charCodeAt(data.length - 1) && | ||
isQuote(quot)) { | ||
data = data.slice(1, -1); | ||
} | ||
} | ||
data = unescapeCSS(data); | ||
} | ||
} | ||
tokens.push({ type: SelectorType.Pseudo, name, data }); | ||
break; | ||
} else if (reName.test(selector.slice(selectorIndex))) { | ||
name = getName(0); | ||
} else { | ||
break loop; | ||
} | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 124 /* Pipe */) { | ||
namespace = name; | ||
if (selector.charCodeAt(selectorIndex + 1) === 42 /* Asterisk */) { | ||
name = "*"; | ||
selectorIndex += 2; | ||
} else { | ||
name = getName(1); | ||
} | ||
case 44 /* Comma */: { | ||
finalizeSubselector(); | ||
tokens = []; | ||
stripWhitespace(1); | ||
break; | ||
} | ||
default: { | ||
if (selector.startsWith("/*", selectorIndex)) { | ||
const endIndex = selector.indexOf("*/", selectorIndex + 2); | ||
if (endIndex < 0) { | ||
throw new Error("Comment was not terminated"); | ||
} | ||
selectorIndex = endIndex + 2; | ||
// Remove leading whitespace | ||
if (tokens.length === 0) { | ||
stripWhitespace(0); | ||
} | ||
break; | ||
} | ||
let namespace = null; | ||
let name; | ||
if (firstChar === 42 /* Asterisk */) { | ||
selectorIndex += 1; | ||
name = "*"; | ||
} | ||
else if (firstChar === 124 /* Pipe */) { | ||
name = ""; | ||
if (selector.charCodeAt(selectorIndex + 1) === 124 /* Pipe */) { | ||
addTraversal(SelectorType.ColumnCombinator); | ||
stripWhitespace(2); | ||
break; | ||
} | ||
} | ||
else if (reName.test(selector.slice(selectorIndex))) { | ||
name = getName(0); | ||
} | ||
else { | ||
break loop; | ||
} | ||
if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && | ||
selector.charCodeAt(selectorIndex + 1) !== 124 /* Pipe */) { | ||
namespace = name; | ||
if (selector.charCodeAt(selectorIndex + 1) === | ||
42 /* Asterisk */) { | ||
name = "*"; | ||
selectorIndex += 2; | ||
} | ||
else { | ||
name = getName(1); | ||
} | ||
} | ||
tokens.push(name === "*" | ||
? { type: SelectorType.Universal, namespace } | ||
: { type: SelectorType.Tag, name, namespace }); | ||
} | ||
} | ||
tokens.push(name === "*" ? { | ||
type: SelectorType.Universal, | ||
namespace | ||
} : { | ||
type: SelectorType.Tag, | ||
name, | ||
namespace | ||
}); | ||
} | ||
} | ||
finalizeSubselector(); | ||
return selectorIndex; | ||
} | ||
finalizeSubselector(); | ||
return selectorIndex; | ||
} | ||
@@ -483,3 +449,2 @@ | ||
let idCache = null; | ||
function buildCache(container) { | ||
@@ -489,6 +454,4 @@ classCache = new Set(); | ||
const queue = [container]; | ||
while (queue.length) { | ||
const node = queue.shift(); | ||
if (node.hasAttribute('class')) { | ||
@@ -500,3 +463,2 @@ const classList = node.getAttribute('class').trim().split(' '); | ||
} | ||
if (node.hasAttribute('id')) { | ||
@@ -506,6 +468,6 @@ const id = node.getAttribute('id').trim(); | ||
} | ||
queue.push(...node.children.filter(child => child.type === 'tag')); | ||
} | ||
} | ||
/** | ||
@@ -516,16 +478,13 @@ * Parse HTML into a mutable, serializable DOM Document. | ||
*/ | ||
function createDocument(html) { | ||
const document = | ||
/** @type {HTMLDocument} */ | ||
htmlparser2.parseDocument(html, { | ||
const document = /** @type {HTMLDocument} */htmlparser2.parseDocument(html, { | ||
decodeEntities: false | ||
}); | ||
defineProperties(document, DocumentExtensions); // Extend Element.prototype with DOM manipulation methods. | ||
defineProperties(document, DocumentExtensions); | ||
defineProperties(domhandler.Element.prototype, ElementExtensions); // Critters container is the viewport to evaluate critical CSS | ||
// Extend Element.prototype with DOM manipulation methods. | ||
defineProperties(domhandler.Element.prototype, ElementExtensions); | ||
// Critters container is the viewport to evaluate critical CSS | ||
let crittersContainer = document.querySelector('[data-critters-container]'); | ||
if (!crittersContainer) { | ||
@@ -535,3 +494,2 @@ document.documentElement.setAttribute('data-critters-container', ''); | ||
} | ||
document.crittersContainer = crittersContainer; | ||
@@ -541,2 +499,3 @@ buildCache(crittersContainer); | ||
} | ||
/** | ||
@@ -546,3 +505,2 @@ * Serialize a Document to an HTML String | ||
*/ | ||
function serializeDocument(document) { | ||
@@ -553,2 +511,3 @@ return render(document, { | ||
} | ||
/** @typedef {treeAdapter.Document & typeof ElementExtensions} HTMLDocument */ | ||
@@ -560,5 +519,5 @@ | ||
*/ | ||
const ElementExtensions = { | ||
/** @extends treeAdapter.Element.prototype */ | ||
nodeName: { | ||
@@ -568,7 +527,5 @@ get() { | ||
} | ||
}, | ||
id: reflectedProperty('id'), | ||
className: reflectedProperty('class'), | ||
insertBefore(child, referenceNode) { | ||
@@ -579,3 +536,2 @@ if (!referenceNode) return this.appendChild(child); | ||
}, | ||
appendChild(child) { | ||
@@ -585,11 +541,8 @@ htmlparser2.DomUtils.appendChild(this, child); | ||
}, | ||
removeChild(child) { | ||
htmlparser2.DomUtils.removeElement(child); | ||
}, | ||
remove() { | ||
htmlparser2.DomUtils.removeElement(this); | ||
}, | ||
textContent: { | ||
@@ -599,3 +552,2 @@ get() { | ||
}, | ||
set(text) { | ||
@@ -605,5 +557,3 @@ this.children = []; | ||
} | ||
}, | ||
setAttribute(name, value) { | ||
@@ -614,3 +564,2 @@ if (this.attribs == null) this.attribs = {}; | ||
}, | ||
removeAttribute(name) { | ||
@@ -621,11 +570,8 @@ if (this.attribs != null) { | ||
}, | ||
getAttribute(name) { | ||
return this.attribs != null && this.attribs[name]; | ||
}, | ||
hasAttribute(name) { | ||
return this.attribs != null && this.attribs[name] != null; | ||
}, | ||
getAttributeNode(name) { | ||
@@ -638,16 +584,13 @@ const value = this.getAttribute(name); | ||
}, | ||
exists(sel) { | ||
return cachedQuerySelector(sel, this); | ||
}, | ||
querySelector(sel) { | ||
return cssSelect.selectOne(sel, this); | ||
}, | ||
querySelectorAll(sel) { | ||
return cssSelect.selectAll(sel, this); | ||
} | ||
}; | ||
}; | ||
/** | ||
@@ -657,5 +600,5 @@ * Methods and descriptors to mix into the global document instance | ||
*/ | ||
const DocumentExtensions = { | ||
/** @extends treeAdapter.Document.prototype */ | ||
// document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE. | ||
@@ -667,3 +610,2 @@ // TODO: verify if these are needed for css-select | ||
} | ||
}, | ||
@@ -674,3 +616,2 @@ contentType: { | ||
} | ||
}, | ||
@@ -681,3 +622,2 @@ nodeName: { | ||
} | ||
}, | ||
@@ -689,3 +629,2 @@ documentElement: { | ||
} | ||
}, | ||
@@ -696,3 +635,2 @@ head: { | ||
} | ||
}, | ||
@@ -703,9 +641,6 @@ body: { | ||
} | ||
}, | ||
createElement(name) { | ||
return new domhandler.Element(name); | ||
}, | ||
createTextNode(text) { | ||
@@ -715,11 +650,8 @@ // there is no dedicated createTextNode equivalent exposed in htmlparser2's DOM | ||
}, | ||
exists(sel) { | ||
return cachedQuerySelector(sel, this); | ||
}, | ||
querySelector(sel) { | ||
return cssSelect.selectOne(sel, this); | ||
}, | ||
querySelectorAll(sel) { | ||
@@ -729,7 +661,6 @@ if (sel === ':root') { | ||
} | ||
return cssSelect.selectAll(sel, this); | ||
} | ||
}; | ||
}; | ||
/** | ||
@@ -739,3 +670,2 @@ * Essentially `Object.defineProperties()`, except function values are assigned as value descriptors for convenience. | ||
*/ | ||
function defineProperties(obj, properties) { | ||
@@ -749,2 +679,3 @@ for (const i in properties) { | ||
} | ||
/** | ||
@@ -754,4 +685,2 @@ * Create a property descriptor defining a getter/setter pair alias for a named attribute. | ||
*/ | ||
function reflectedProperty(attributeName) { | ||
@@ -762,13 +691,9 @@ return { | ||
}, | ||
set(value) { | ||
this.setAttribute(attributeName, value); | ||
} | ||
}; | ||
} | ||
function cachedQuerySelector(sel, node) { | ||
const selectorTokens = parse(sel); | ||
for (const tokens of selectorTokens) { | ||
@@ -778,7 +703,5 @@ // Check if the selector is a class selector | ||
const token = tokens[0]; | ||
if (token.type === 'attribute' && token.name === 'class') { | ||
return classCache.has(token.value); | ||
} | ||
if (token.type === 'attribute' && token.name === 'id') { | ||
@@ -789,3 +712,2 @@ return idCache.has(token.value); | ||
} | ||
return !!cssSelect.selectOne(sel, node); | ||
@@ -809,2 +731,3 @@ } | ||
*/ | ||
/** | ||
@@ -818,6 +741,6 @@ * Parse a textual CSS Stylesheet into a Stylesheet instance. | ||
*/ | ||
function parseStylesheet(stylesheet) { | ||
return postcss.parse(stylesheet); | ||
} | ||
/** | ||
@@ -830,3 +753,2 @@ * Serialize a postcss Stylesheet to a String of CSS. | ||
*/ | ||
function serializeStylesheet(ast, options) { | ||
@@ -836,11 +758,12 @@ let cssStr = ''; | ||
var _node$raws; | ||
if ((node == null ? void 0 : node.type) === 'decl' && node.value.includes('</style>')) { | ||
return; | ||
} | ||
if (!options.compress) { | ||
cssStr += result; | ||
return; | ||
} // Simple minification logic | ||
} | ||
// Simple minification logic | ||
if ((node == null ? void 0 : node.type) === 'comment') return; | ||
if ((node == null ? void 0 : node.type) === 'decl') { | ||
@@ -851,3 +774,2 @@ const prefix = node.prop + node.raws.between; | ||
} | ||
if (type === 'start') { | ||
@@ -859,10 +781,7 @@ if (node.type === 'rule' && node.selectors) { | ||
} | ||
return; | ||
} | ||
if (type === 'end' && result === '}' && node != null && (_node$raws = node.raws) != null && _node$raws.semicolon) { | ||
cssStr = cssStr.slice(0, -1); | ||
} | ||
cssStr += result.trim(); | ||
@@ -872,2 +791,3 @@ }); | ||
} | ||
/** | ||
@@ -880,20 +800,16 @@ * Converts a walkStyleRules() iterator to mark nodes with `.$$remove=true` instead of actually removing them. | ||
*/ | ||
function markOnly(predicate) { | ||
return rule => { | ||
const sel = rule.selectors; | ||
if (predicate(rule) === false) { | ||
rule.$$remove = true; | ||
} | ||
rule.$$markedSelectors = rule.selectors; | ||
if (rule._other) { | ||
rule._other.$$markedSelectors = rule._other.selectors; | ||
} | ||
rule.selectors = sel; | ||
}; | ||
} | ||
/** | ||
@@ -904,3 +820,2 @@ * Apply filtered selectors to a rule from a previous markOnly run. | ||
*/ | ||
function applyMarkedSelectors(rule) { | ||
@@ -910,3 +825,2 @@ if (rule.$$markedSelectors) { | ||
} | ||
if (rule._other) { | ||
@@ -916,2 +830,3 @@ applyMarkedSelectors(rule._other); | ||
} | ||
/** | ||
@@ -923,3 +838,2 @@ * Recursively walk all rules in a stylesheet. | ||
*/ | ||
function walkStyleRules(node, iterator) { | ||
@@ -930,3 +844,2 @@ node.nodes = node.nodes.filter(rule => { | ||
} | ||
rule._other = undefined; | ||
@@ -937,2 +850,3 @@ rule.filterSelectors = filterSelectors; | ||
} | ||
/** | ||
@@ -945,3 +859,2 @@ * Recursively walk all rules in two identical stylesheets, filtering nodes into one or the other based on a predicate. | ||
*/ | ||
function walkStyleRulesWithReverseMirror(node, node2, iterator) { | ||
@@ -951,7 +864,5 @@ if (node2 === null) return walkStyleRules(node, iterator); | ||
const rule2 = rules2[index]; | ||
if (hasNestedRules(rule)) { | ||
walkStyleRulesWithReverseMirror(rule, rule2, iterator); | ||
} | ||
rule._other = rule2; | ||
@@ -961,15 +872,15 @@ rule.filterSelectors = filterSelectors; | ||
}); | ||
} // Checks if a node has nested rules, like @media | ||
} | ||
// Checks if a node has nested rules, like @media | ||
// @keyframes are an exception since they are evaluated as a whole | ||
function hasNestedRules(rule) { | ||
return rule.nodes && rule.nodes.length && rule.nodes.some(n => n.type === 'rule' || n.type === 'atrule') && rule.name !== 'keyframes' && rule.name !== '-webkit-keyframes'; | ||
} // Like [].filter(), but applies the opposite filtering result to a second copy of the Array without a second pass. | ||
} | ||
// Like [].filter(), but applies the opposite filtering result to a second copy of the Array without a second pass. | ||
// This is just a quicker version of generating the compliment of the set returned from a filter operation. | ||
function splitFilter(a, b, predicate) { | ||
const aOut = []; | ||
const bOut = []; | ||
for (let index = 0; index < a.length; index++) { | ||
@@ -982,7 +893,6 @@ if (predicate(a[index], index, a, b)) { | ||
} | ||
return [aOut, bOut]; | ||
} // can be invoked on a style rule to subset its selectors (with reverse mirroring) | ||
} | ||
// can be invoked on a style rule to subset its selectors (with reverse mirroring) | ||
function filterSelectors(predicate) { | ||
@@ -997,3 +907,46 @@ if (this._other) { | ||
} | ||
const MEDIA_TYPES = new Set(['all', 'print', 'screen', 'speech']); | ||
const MEDIA_KEYWORDS = new Set(['and', 'not', ',']); | ||
const MEDIA_FEATURES = ['width', 'aspect-ratio', 'color', 'color-index', 'grid', 'height', 'monochrome', 'orientation', 'resolution', 'scan']; | ||
function validateMediaType(node) { | ||
const { | ||
type: nodeType, | ||
value: nodeValue | ||
} = node; | ||
if (nodeType === 'media-type') { | ||
return MEDIA_TYPES.has(nodeValue); | ||
} else if (nodeType === 'keyword') { | ||
return MEDIA_KEYWORDS.has(nodeValue); | ||
} else if (nodeType === 'media-feature') { | ||
return MEDIA_FEATURES.some(feature => { | ||
return nodeValue === feature || nodeValue === `min-${feature}` || nodeValue === `max-${feature}`; | ||
}); | ||
} | ||
} | ||
/** | ||
* | ||
* @param {string} Media query to validate | ||
* @returns {boolean} | ||
* | ||
* This function performs a basic media query validation | ||
* to ensure the values passed as part of the 'media' config | ||
* is HTML safe and does not cause any injection issue | ||
*/ | ||
function validateMediaQuery(query) { | ||
const mediaTree = mediaParser(query); | ||
const nodeTypes = new Set(['media-type', 'keyword', 'media-feature']); | ||
const stack = [mediaTree]; | ||
while (stack.length > 0) { | ||
const node = stack.pop(); | ||
if (nodeTypes.has(node.type) && !validateMediaType(node)) { | ||
return false; | ||
} | ||
if (node.nodes) { | ||
stack.push(...node.nodes); | ||
} | ||
} | ||
return true; | ||
} | ||
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'silent']; | ||
@@ -1004,21 +957,15 @@ const defaultLogger = { | ||
}, | ||
debug(msg) { | ||
console.debug(msg); | ||
}, | ||
warn(msg) { | ||
console.warn(chalk.yellow(msg)); | ||
}, | ||
error(msg) { | ||
console.error(chalk.bold.red(msg)); | ||
}, | ||
info(msg) { | ||
console.info(chalk.bold.blue(msg)); | ||
}, | ||
silent() {} | ||
}; | ||
@@ -1033,6 +980,8 @@ function createLogger(logLevel) { | ||
} | ||
return logger; | ||
}, {}); | ||
} | ||
function isSubpath(basePath, currentPath) { | ||
return !path.relative(basePath, currentPath).startsWith('..'); | ||
} | ||
@@ -1054,2 +1003,3 @@ /** | ||
*/ | ||
/** | ||
@@ -1152,14 +1102,11 @@ * The mechanism to use for lazy-loading stylesheets. | ||
this.urlFilter = this.options.filter; | ||
if (this.urlFilter instanceof RegExp) { | ||
this.urlFilter = this.urlFilter.test.bind(this.urlFilter); | ||
} | ||
this.logger = this.options.logger || createLogger(this.options.logLevel); | ||
} | ||
/** | ||
* Read the contents of a file from the specified filesystem or disk | ||
*/ | ||
readFile(filename) { | ||
@@ -1171,3 +1118,2 @@ const fs$1 = this.fs; | ||
}; | ||
if (fs$1 && fs$1.readFile) { | ||
@@ -1180,31 +1126,29 @@ fs$1.readFile(filename, callback); | ||
} | ||
/** | ||
* Apply critical CSS processing to the html | ||
*/ | ||
async process(html) { | ||
const start = process.hrtime.bigint(); // Parse the generated HTML in a DOM we can mutate | ||
const start = process.hrtime.bigint(); | ||
// Parse the generated HTML in a DOM we can mutate | ||
const document = createDocument(html); | ||
if (this.options.additionalStylesheets.length > 0) { | ||
this.embedAdditionalStylesheet(document); | ||
} // `external:false` skips processing of external sheets | ||
} | ||
// `external:false` skips processing of external sheets | ||
if (this.options.external !== false) { | ||
const externalSheets = [].slice.call(document.querySelectorAll('link[rel="stylesheet"]')); | ||
await Promise.all(externalSheets.map(link => this.embedLinkedStylesheet(link, document))); | ||
} // go through all the style tags in the document and reduce them to only critical CSS | ||
} | ||
// go through all the style tags in the document and reduce them to only critical CSS | ||
const styles = this.getAffectedStyleTags(document); | ||
await Promise.all(styles.map(style => this.processStyle(style, document))); | ||
if (this.options.mergeStylesheets !== false && styles.length !== 0) { | ||
await this.mergeStylesheets(document); | ||
} // serialize the document back to HTML and we're done | ||
} | ||
// serialize the document back to HTML and we're done | ||
const output = serializeDocument(document); | ||
@@ -1215,20 +1159,17 @@ const end = process.hrtime.bigint(); | ||
} | ||
/** | ||
* Get the style tags that need processing | ||
*/ | ||
getAffectedStyleTags(document) { | ||
const styles = [].slice.call(document.querySelectorAll('style')); // `inline:false` skips processing of inline stylesheets | ||
const styles = [].slice.call(document.querySelectorAll('style')); | ||
// `inline:false` skips processing of inline stylesheets | ||
if (this.options.reduceInlineStyles === false) { | ||
return styles.filter(style => style.$$external); | ||
} | ||
return styles; | ||
} | ||
async mergeStylesheets(document) { | ||
const styles = this.getAffectedStyleTags(document); | ||
if (styles.length === 0) { | ||
@@ -1238,6 +1179,4 @@ this.logger.warn('Merging inline stylesheets into a single <style> tag skipped, no inline stylesheets to merge'); | ||
} | ||
const first = styles[0]; | ||
let sheet = first.textContent; | ||
for (let i = 1; i < styles.length; i++) { | ||
@@ -1248,30 +1187,30 @@ const node = styles[i]; | ||
} | ||
first.textContent = sheet; | ||
} | ||
/** | ||
* Given href, find the corresponding CSS asset | ||
*/ | ||
async getCssAsset(href) { | ||
const outputPath = this.options.path; | ||
const publicPath = this.options.publicPath; // CHECK - the output path | ||
const publicPath = this.options.publicPath; | ||
// CHECK - the output path | ||
// path on disk (with output.publicPath removed) | ||
let normalizedPath = href.replace(/^\//, ''); | ||
const pathPrefix = (publicPath || '').replace(/(^\/|\/$)/g, '') + '/'; | ||
if (normalizedPath.indexOf(pathPrefix) === 0) { | ||
normalizedPath = normalizedPath.substring(pathPrefix.length).replace(/^\//, ''); | ||
} // Ignore remote stylesheets | ||
} | ||
// Ignore remote stylesheets | ||
if (/^https?:\/\//.test(normalizedPath) || href.startsWith('//')) { | ||
return undefined; | ||
} | ||
const filename = path.resolve(outputPath, normalizedPath); | ||
// Check if the resolved path is valid | ||
if (!isSubpath(outputPath, filename)) { | ||
return undefined; | ||
} | ||
let sheet; | ||
try { | ||
@@ -1282,6 +1221,4 @@ sheet = await this.readFile(filename); | ||
} | ||
return sheet; | ||
} | ||
checkInlineThreshold(link, style, sheet) { | ||
@@ -1295,10 +1232,8 @@ if (this.options.inlineThreshold && sheet.length < this.options.inlineThreshold) { | ||
} | ||
return false; | ||
} | ||
/** | ||
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`) | ||
*/ | ||
async embedAdditionalStylesheet(document) { | ||
@@ -1310,3 +1245,2 @@ const styleSheetsIncluded = []; | ||
} | ||
styleSheetsIncluded.push(cssFile); | ||
@@ -1323,25 +1257,26 @@ const style = document.createElement('style'); | ||
} | ||
/** | ||
* Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`) | ||
*/ | ||
async embedLinkedStylesheet(link, document) { | ||
const href = link.getAttribute('href'); | ||
const media = link.getAttribute('media'); | ||
const preloadMode = this.options.preload; // skip filtered resources, or network resources if no filter is provided | ||
let media = link.getAttribute('media'); | ||
if (media && !validateMediaQuery(media)) { | ||
media = undefined; | ||
} | ||
const preloadMode = this.options.preload; | ||
// skip filtered resources, or network resources if no filter is provided | ||
if (this.urlFilter ? this.urlFilter(href) : !(href || '').match(/\.css$/)) { | ||
return Promise.resolve(); | ||
} // the reduced critical CSS gets injected into a new <style> tag | ||
} | ||
// the reduced critical CSS gets injected into a new <style> tag | ||
const style = document.createElement('style'); | ||
style.$$external = true; | ||
const sheet = await this.getCssAsset(href, style); | ||
if (!sheet) { | ||
return; | ||
} | ||
style.textContent = sheet; | ||
@@ -1351,19 +1286,16 @@ style.$$name = href; | ||
link.parentNode.insertBefore(style, link); | ||
if (this.checkInlineThreshold(link, style, sheet)) { | ||
return; | ||
} // CSS loader is only injected for the first sheet, then this becomes an empty string | ||
} | ||
// CSS loader is only injected for the first sheet, then this becomes an empty string | ||
let cssLoaderPreamble = "function $loadcss(u,m,l){(l=document.createElement('link')).rel='stylesheet';l.href=u;document.head.appendChild(l)}"; | ||
const lazy = preloadMode === 'js-lazy'; | ||
if (lazy) { | ||
cssLoaderPreamble = cssLoaderPreamble.replace('l.href', "l.media='print';l.onload=function(){l.media=m};l.href"); | ||
} // Allow disabling any mutation of the stylesheet link: | ||
} | ||
// Allow disabling any mutation of the stylesheet link: | ||
if (preloadMode === false) return; | ||
let noscriptFallback = false; | ||
if (preloadMode === 'body') { | ||
@@ -1374,7 +1306,8 @@ document.body.appendChild(link); | ||
link.setAttribute('as', 'style'); | ||
if (preloadMode === 'js' || preloadMode === 'js-lazy') { | ||
const script = document.createElement('script'); | ||
const js = `${cssLoaderPreamble}$loadcss(${JSON.stringify(href)}${lazy ? ',' + JSON.stringify(media || 'all') : ''})`; // script.appendChild(document.createTextNode(js)); | ||
script.setAttribute('data-href', href); | ||
script.setAttribute('data-media', media || 'all'); | ||
const js = `${cssLoaderPreamble}$loadcss(document.currentScript.dataset.href,document.currentScript.dataset.media)`; | ||
// script.appendChild(document.createTextNode(js)); | ||
script.textContent = js; | ||
@@ -1410,4 +1343,5 @@ link.parentNode.insertBefore(script, link.nextSibling); | ||
} | ||
if (this.options.noscriptFallback !== false && noscriptFallback) { | ||
if (this.options.noscriptFallback !== false && noscriptFallback && | ||
// Don't parse the URL if it contains </noscript> as it might cause unexpected behavior | ||
!href.includes('</noscript>')) { | ||
const noscript = document.createElement('noscript'); | ||
@@ -1423,7 +1357,6 @@ const noscriptLink = document.createElement('link'); | ||
} | ||
/** | ||
* Prune the source CSS files | ||
*/ | ||
pruneSource(style, before, sheetInverse) { | ||
@@ -1433,7 +1366,6 @@ // if external stylesheet would be below minimum size, just inline everything | ||
const name = style.$$name; | ||
if (minSize && sheetInverse.length < minSize) { | ||
this.logger.info(`\u001b[32mInlined all of ${name} (non-critical external stylesheet would have been ${sheetInverse.length}b, which was below the threshold of ${minSize})\u001b[39m`); | ||
style.textContent = before; // remove any associated external resources/loaders: | ||
style.textContent = before; | ||
// remove any associated external resources/loaders: | ||
if (style.$$links) { | ||
@@ -1445,13 +1377,10 @@ for (const link of style.$$links) { | ||
} | ||
return true; | ||
} | ||
return false; | ||
} | ||
/** | ||
* Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document. | ||
*/ | ||
async processStyle(style, document) { | ||
@@ -1462,14 +1391,17 @@ if (style.$$reduce === false) return; | ||
const crittersContainer = document.crittersContainer; | ||
let keyframesMode = options.keyframes || 'critical'; // we also accept a boolean value for options.keyframes | ||
let keyframesMode = options.keyframes || 'critical'; | ||
// we also accept a boolean value for options.keyframes | ||
if (keyframesMode === true) keyframesMode = 'all'; | ||
if (keyframesMode === false) keyframesMode = 'none'; | ||
let sheet = style.textContent; // store a reference to the previous serialized stylesheet for reporting stats | ||
let sheet = style.textContent; | ||
const before = sheet; // Skip empty stylesheets | ||
// store a reference to the previous serialized stylesheet for reporting stats | ||
const before = sheet; | ||
// Skip empty stylesheets | ||
if (!sheet) return; | ||
const ast = parseStylesheet(sheet); | ||
const astInverse = options.pruneSource ? parseStylesheet(sheet) : null; // a string to search for font names (very loose) | ||
const astInverse = options.pruneSource ? parseStylesheet(sheet) : null; | ||
// a string to search for font names (very loose) | ||
let criticalFonts = ''; | ||
@@ -1481,12 +1413,11 @@ const failedSelectors = []; | ||
let excludeNext = false; | ||
let excludeAll = false; // Walk all CSS rules, marking unused rules with `.$$remove=true` for removal in the second pass. | ||
let excludeAll = false; | ||
// Walk all CSS rules, marking unused rules with `.$$remove=true` for removal in the second pass. | ||
// This first pass is also used to collect font and keyframe usage used in the second pass. | ||
walkStyleRules(ast, markOnly(rule => { | ||
if (rule.type === 'comment') { | ||
const comment = rule.text.trim(); | ||
if (comment.startsWith('critters')) { | ||
const command = comment.replace(/^critters:/, ''); | ||
switch (command) { | ||
@@ -1496,19 +1427,14 @@ case 'include': | ||
break; | ||
case 'exclude': | ||
excludeNext = true; | ||
break; | ||
case 'include start': | ||
includeAll = true; | ||
break; | ||
case 'include end': | ||
includeAll = false; | ||
break; | ||
case 'exclude start': | ||
excludeAll = true; | ||
break; | ||
case 'exclude end': | ||
@@ -1520,3 +1446,2 @@ excludeAll = false; | ||
} | ||
if (rule.type === 'rule') { | ||
@@ -1528,3 +1453,2 @@ // Handle comment based markers | ||
} | ||
if (excludeNext) { | ||
@@ -1534,12 +1458,10 @@ excludeNext = false; | ||
} | ||
if (includeAll) { | ||
return true; | ||
} | ||
if (excludeAll) { | ||
return false; | ||
} // Filter the selector list down to only those match | ||
} | ||
// Filter the selector list down to only those match | ||
rule.filterSelectors(sel => { | ||
@@ -1551,16 +1473,15 @@ // Validate rule with 'allowRules' option | ||
} | ||
return exp === sel; | ||
}); | ||
if (isAllowedRule) return true; // Strip pseudo-elements and pseudo-classes, since we only care that their associated elements exist. | ||
if (isAllowedRule) return true; | ||
// Strip pseudo-elements and pseudo-classes, since we only care that their associated elements exist. | ||
// This means any selector for a pseudo-element or having a pseudo-class will be inlined if the rest of the selector matches. | ||
if (sel === ':root' || sel.match(/^::?(before|after)$/) || sel === 'html' || sel === 'body') { | ||
return true; | ||
} | ||
sel = sel.replace(/(?<!\\)::?[a-z-]+(?![a-z-(])/gi, '').replace(/::?not\(\s*\)/g, '') // Remove tailing or leading commas from cleaned sub selector `is(.active, :hover)` -> `is(.active)`. | ||
sel = sel.replace(/(?<!\\)::?[a-z-]+(?![a-z-(])/gi, '').replace(/::?not\(\s*\)/g, '') | ||
// Remove tailing or leading commas from cleaned sub selector `is(.active, :hover)` -> `is(.active)`. | ||
.replace(/\(\s*,/g, '(').replace(/,\s*\)/g, ')').trim(); | ||
if (!sel) return false; | ||
try { | ||
@@ -1572,21 +1493,21 @@ return crittersContainer.exists(sel); | ||
} | ||
}); // If there are no matched selectors, remove the rule: | ||
}); | ||
// If there are no matched selectors, remove the rule: | ||
if (!rule.selector) { | ||
return false; | ||
} | ||
if (rule.nodes) { | ||
for (let i = 0; i < rule.nodes.length; i++) { | ||
const decl = rule.nodes[i]; // detect used fonts | ||
const decl = rule.nodes[i]; | ||
// detect used fonts | ||
if (decl.prop && decl.prop.match(/\bfont(-family)?\b/i)) { | ||
criticalFonts += ' ' + decl.value; | ||
} // detect used keyframes | ||
} | ||
// detect used keyframes | ||
if (decl.prop === 'animation' || decl.prop === 'animation-name') { | ||
// @todo: parse animation declarations and extract only the name. for now we'll do a lazy match. | ||
const names = decl.value.split(/\s+/); | ||
for (let j = 0; j < names.length; j++) { | ||
@@ -1599,24 +1520,24 @@ const name = names[j].trim(); | ||
} | ||
} // keep font rules, they're handled in the second pass: | ||
} | ||
// keep font rules, they're handled in the second pass: | ||
if (rule.type === 'atrule' && rule.name === 'font-face') return; | ||
if (rule.type === 'atrule' && rule.name === 'font-face') return; // If there are no remaining rules, remove the whole rule: | ||
// If there are no remaining rules, remove the whole rule: | ||
const rules = rule.nodes && rule.nodes.filter(rule => !rule.$$remove); | ||
return !rules || rules.length !== 0; | ||
})); | ||
if (failedSelectors.length !== 0) { | ||
this.logger.warn(`${failedSelectors.length} rules skipped due to selector errors:\n ${failedSelectors.join('\n ')}`); | ||
} | ||
const shouldPreloadFonts = options.fonts === true || options.preloadFonts === true; | ||
const shouldInlineFonts = options.fonts !== false && options.inlineFonts === true; | ||
const preloadedFonts = []; // Second pass, using data picked up from the first | ||
const preloadedFonts = []; | ||
// Second pass, using data picked up from the first | ||
walkStyleRulesWithReverseMirror(ast, astInverse, rule => { | ||
// remove any rules marked in the first pass | ||
if (rule.$$remove === true) return false; | ||
applyMarkedSelectors(rule); // prune @keyframes rules | ||
applyMarkedSelectors(rule); | ||
// prune @keyframes rules | ||
if (rule.type === 'atrule' && rule.name === 'keyframes') { | ||
@@ -1626,11 +1547,9 @@ if (keyframesMode === 'none') return false; | ||
return criticalKeyframeNames.indexOf(rule.params) !== -1; | ||
} // prune @font-face rules | ||
} | ||
// prune @font-face rules | ||
if (rule.type === 'atrule' && rule.name === 'font-face') { | ||
let family, src; | ||
for (let i = 0; i < rule.nodes.length; i++) { | ||
const decl = rule.nodes[i]; | ||
if (decl.prop === 'src') { | ||
@@ -1643,3 +1562,2 @@ // @todo parse this properly and generate multiple preloads with type="font/woff2" etc | ||
} | ||
if (src && shouldPreloadFonts && preloadedFonts.indexOf(src) === -1) { | ||
@@ -1653,5 +1571,5 @@ preloadedFonts.push(src); | ||
document.head.appendChild(preload); | ||
} // if we're missing info, if the font is unused, or if critical font inlining is disabled, remove the rule: | ||
} | ||
// if we're missing info, if the font is unused, or if critical font inlining is disabled, remove the rule: | ||
if (!family || !src || criticalFonts.indexOf(family) === -1 || !shouldInlineFonts) { | ||
@@ -1664,4 +1582,5 @@ return false; | ||
compress: this.options.compress !== false | ||
}); // If all rules were removed, get rid of the style element entirely | ||
}); | ||
// If all rules were removed, get rid of the style element entirely | ||
if (sheet.trim().length === 0) { | ||
@@ -1671,9 +1590,6 @@ if (style.parentNode) { | ||
} | ||
return; | ||
} | ||
let afterText = ''; | ||
let styleInlinedCompletely = false; | ||
if (options.pruneSource) { | ||
@@ -1684,3 +1600,2 @@ const sheetInverse = serializeStylesheet(astInverse, { | ||
styleInlinedCompletely = this.pruneSource(style, before, sheetInverse); | ||
if (styleInlinedCompletely) { | ||
@@ -1690,16 +1605,15 @@ const percent = sheetInverse.length / before.length * 100; | ||
} | ||
} // replace the inline stylesheet with its critical'd counterpart | ||
} | ||
// replace the inline stylesheet with its critical'd counterpart | ||
if (!styleInlinedCompletely) { | ||
style.textContent = sheet; | ||
} // output stats | ||
} | ||
// output stats | ||
const percent = sheet.length / before.length * 100 | 0; | ||
this.logger.info('\u001b[32mInlined ' + prettyBytes(sheet.length) + ' (' + percent + '% of original ' + prettyBytes(before.length) + ') of ' + name + afterText + '.\u001b[39m'); | ||
} | ||
} | ||
module.exports = Critters; |
{ | ||
"name": "critters", | ||
"version": "0.0.20", | ||
"version": "0.0.21", | ||
"description": "Inline critical CSS and lazy-load the rest.", | ||
@@ -55,2 +55,3 @@ "main": "dist/critters.js", | ||
"postcss": "^8.4.23", | ||
"postcss-media-query-parser": "^0.2.3", | ||
"pretty-bytes": "^5.3.0" | ||
@@ -57,0 +58,0 @@ }, |
@@ -18,2 +18,3 @@ /** | ||
import { parse, stringify } from 'postcss'; | ||
import mediaParser from 'postcss-media-query-parser'; | ||
@@ -43,2 +44,6 @@ /** | ||
stringify(ast, (result, node, type) => { | ||
if (node?.type === 'decl' && node.value.includes('</style>')) { | ||
return; | ||
} | ||
if (!options.compress) { | ||
@@ -196,1 +201,63 @@ cssStr += result; | ||
} | ||
const MEDIA_TYPES = new Set(['all', 'print', 'screen', 'speech']); | ||
const MEDIA_KEYWORDS = new Set(['and', 'not', ',']); | ||
const MEDIA_FEATURES = [ | ||
'width', | ||
'aspect-ratio', | ||
'color', | ||
'color-index', | ||
'grid', | ||
'height', | ||
'monochrome', | ||
'orientation', | ||
'resolution', | ||
'scan' | ||
]; | ||
function validateMediaType(node) { | ||
const { type: nodeType, value: nodeValue } = node; | ||
if (nodeType === 'media-type') { | ||
return MEDIA_TYPES.has(nodeValue); | ||
} else if (nodeType === 'keyword') { | ||
return MEDIA_KEYWORDS.has(nodeValue); | ||
} else if (nodeType === 'media-feature') { | ||
return MEDIA_FEATURES.some((feature) => { | ||
return ( | ||
nodeValue === feature || | ||
nodeValue === `min-${feature}` || | ||
nodeValue === `max-${feature}` | ||
); | ||
}); | ||
} | ||
} | ||
/** | ||
* | ||
* @param {string} Media query to validate | ||
* @returns {boolean} | ||
* | ||
* This function performs a basic media query validation | ||
* to ensure the values passed as part of the 'media' config | ||
* is HTML safe and does not cause any injection issue | ||
*/ | ||
export function validateMediaQuery(query) { | ||
const mediaTree = mediaParser(query); | ||
const nodeTypes = new Set(['media-type', 'keyword', 'media-feature']); | ||
const stack = [mediaTree]; | ||
while (stack.length > 0) { | ||
const node = stack.pop(); | ||
if (nodeTypes.has(node.type) && !validateMediaType(node)) { | ||
return false; | ||
} | ||
if (node.nodes) { | ||
stack.push(...node.nodes); | ||
} | ||
} | ||
return true; | ||
} |
@@ -27,5 +27,6 @@ /** | ||
markOnly, | ||
applyMarkedSelectors | ||
applyMarkedSelectors, | ||
validateMediaQuery | ||
} from './css'; | ||
import { createLogger } from './util'; | ||
import { createLogger, isSubpath } from './util'; | ||
@@ -256,2 +257,6 @@ /** | ||
const filename = path.resolve(outputPath, normalizedPath); | ||
// Check if the resolved path is valid | ||
if (!isSubpath(outputPath, filename)) { | ||
return undefined; | ||
} | ||
@@ -316,4 +321,8 @@ let sheet; | ||
const href = link.getAttribute('href'); | ||
const media = link.getAttribute('media'); | ||
let media = link.getAttribute('media'); | ||
if (media && !validateMediaQuery(media)) { | ||
media = undefined; | ||
} | ||
const preloadMode = this.options.preload; | ||
@@ -367,5 +376,5 @@ | ||
const script = document.createElement('script'); | ||
const js = `${cssLoaderPreamble}$loadcss(${JSON.stringify(href)}${ | ||
lazy ? ',' + JSON.stringify(media || 'all') : '' | ||
})`; | ||
script.setAttribute('data-href', href); | ||
script.setAttribute('data-media', media || 'all'); | ||
const js = `${cssLoaderPreamble}$loadcss(document.currentScript.dataset.href,document.currentScript.dataset.media)`; | ||
// script.appendChild(document.createTextNode(js)); | ||
@@ -403,3 +412,8 @@ script.textContent = js; | ||
if (this.options.noscriptFallback !== false && noscriptFallback) { | ||
if ( | ||
this.options.noscriptFallback !== false && | ||
noscriptFallback && | ||
// Don't parse the URL if it contains </noscript> as it might cause unexpected behavior | ||
!href.includes('</noscript>') | ||
) { | ||
const noscript = document.createElement('noscript'); | ||
@@ -556,3 +570,3 @@ const noscriptLink = document.createElement('link'); | ||
.replace(/::?not\(\s*\)/g, '') | ||
// Remove tailing or leading commas from cleaned sub selector `is(.active, :hover)` -> `is(.active)`. | ||
// Remove tailing or leading commas from cleaned sub selector `is(.active, :hover)` -> `is(.active)`. | ||
.replace(/\(\s*,/g, '(') | ||
@@ -559,0 +573,0 @@ .replace(/,\s*\)/g, ')') |
import chalk from 'chalk'; | ||
import path from 'path'; | ||
@@ -41,1 +42,5 @@ const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'silent']; | ||
} | ||
export function isSubpath(basePath, currentPath) { | ||
return !path.relative(basePath, currentPath).startsWith('..'); | ||
} |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
164681
10
4133
8
+ Addedpostcss-media-query-parser@0.2.3(transitive)