UNPKG

jsdom

Version:

A JavaScript implementation of many web standards

228 lines (193 loc) 6.38 kB
"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(`@supports ${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;