jsdom
Version:
A JavaScript implementation of many web standards
228 lines (193 loc) • 6.38 kB
JavaScript
"use strict";
const propertyDefinitions = require("../../../generated/css-property-definitions.js");
const { asciiLowercase } = require("../helpers/strings.js");
const csstree = require("./helpers/patched-csstree.js");
const PROP_VAL_REGEXP = /^([^():]+)\s*:\s*(.+)\s*$/;
class CSSImpl {
#testNode;
constructor(globalObject) {
this._globalObject = globalObject;
}
// https://drafts.csswg.org/cssom/#the-css.escape()-method
escape(ident) {
const l = ident.length;
const firstCodeUnit = l > 0 ? ident.charCodeAt(0) : 0;
let escaped = "";
for (let i = 0; i < l; i++) {
const codeUnit = ident.charCodeAt(i);
// NULL character (U+0000) becomes the replacement character (U+FFFD)
if (codeUnit === 0x0000) {
escaped += "\uFFFD";
continue;
}
// Control characters, a leading digit, or the digit in a "hyphen + digit" sequence
if (
(codeUnit >= 0x0001 && codeUnit <= 0x001F) ||
codeUnit === 0x007F ||
(i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002D)
) {
escaped += `\\${codeUnit.toString(16)} `;
continue;
}
// If the string consists of a single hyphen
if (i === 0 && l === 1 && codeUnit === 0x002D) {
escaped += `\\${ident.charAt(i)}`;
continue;
}
// ASCII characters that do not require escaping (alphanumeric, hyphen, underscore)
// and non-ASCII characters
if (
codeUnit >= 0x0080 ||
codeUnit === 0x002D ||
codeUnit === 0x005F ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005A) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007A)
) {
escaped += ident.charAt(i);
continue;
}
// Escape all other symbols with a backslash
escaped += `\\${ident.charAt(i)}`;
}
return escaped;
}
// https://drafts.csswg.org/css-conditional-3/#the-css-namespace
supports(...args) {
if (args.length === 1) {
return this.#supportsConditionText(args[0].trim());
}
const [property, value] = args;
return this.#supportsDeclaration(property, value);
}
#supportsConditionText(conditionText) {
// Condition without parens (e.g., "color: red")
if (PROP_VAL_REGEXP.test(conditionText)) {
const [, property, value] = PROP_VAL_REGEXP.exec(conditionText);
return this.#supportsDeclaration(property, value);
}
const ast = csstree.parse(` ${conditionText} {}`, { context: "stylesheet" });
return this.#evaluateList(ast.children.first.prelude?.children);
}
#supportsDeclaration(property, value) {
// Custom property
if (property.startsWith("--")) {
try {
const ast = csstree.parse(value, { context: "value" });
if (ast) {
return true;
}
} catch {
// Fall through
}
return false;
}
const lowerProp = asciiLowercase(property);
if (!propertyDefinitions.has(lowerProp)) {
return false;
}
return this.#isValueSupported(lowerProp, value);
}
#evaluateList(list) {
if (!list || !list.head) {
return false;
}
let result = null;
let currentOperator = null;
let isNot = false;
let expectingCondition = true;
let current = list.head;
while (current !== null) {
const node = current.data;
const { children, declaration, name, property, type, value } = node;
if (type === "WhiteSpace" || type === "Comment") {
current = current.next;
continue;
} else if (type === "Identifier") {
const ident = asciiLowercase(name);
if (ident === "not") {
if (result !== null || isNot) {
return false;
}
isNot = true;
} else if (ident === "and" || ident === "or") {
if (isNot || expectingCondition) {
return false;
} else if (currentOperator !== null && currentOperator !== ident) {
return false;
}
currentOperator = ident;
expectingCondition = true;
} else {
// Any other unexpected identifier makes the condition invalid
return false;
}
current = current.next;
continue;
}
if (!expectingCondition) {
return false;
}
let childResult;
switch (type) {
case "Parentheses":
case "Condition": {
childResult = this.#evaluateList(children);
break;
}
case "Declaration": {
childResult = this.#validateDeclarationAST(property, value);
break;
}
case "SupportsDeclaration": {
childResult = this.#validateDeclarationAST(declaration.property, declaration.value);
break;
}
default: {
// TODO: Support Function types (e.g., selector())
return false;
}
}
if (isNot) {
childResult = !childResult;
isNot = false;
}
if (result === null) {
result = childResult;
} else if (currentOperator === "and") {
result &&= childResult;
} else if (currentOperator === "or") {
result ||= childResult;
}
expectingCondition = false;
current = current.next;
}
// Catch unexpected end of input (e.g., CSS.supports("not"), CSS.supports("(color: red) and"))
if (isNot || (expectingCondition && result !== null)) {
return false;
}
return result === null ? false : result;
}
#validateDeclarationAST(property, ast) {
// Custom property
if (property.startsWith("--")) {
return true;
}
const lowerProp = asciiLowercase(property);
if (!propertyDefinitions.has(lowerProp)) {
return false;
}
return this.#isValueSupported(lowerProp, csstree.generate(ast));
}
#isValueSupported(property, value) {
if (!this.#testNode) {
this.#testNode = this._globalObject.document.createElement("div");
}
this.#testNode.style.setProperty(property, value);
const isSupported = this.#testNode.style.getPropertyValue(property) !== "";
this.#testNode.style.cssText = "";
return isSupported;
}
}
exports.implementation = CSSImpl;