jsdom
Version:
A JavaScript implementation of many web standards
392 lines (354 loc) • 15 kB
JavaScript
;
const fs = require("node:fs");
const path = require("node:path");
const Specificity = require("@bramus/specificity").default;
const { longhandsByShorthand, shorthandsByLonghand } =
require("../../../../generated/css-shorthand-relationships.js");
const CSSImportRule = require("../../../../generated/idl/CSSImportRule.js");
const CSSMediaRule = require("../../../../generated/idl/CSSMediaRule.js");
const CSSStyleProperties = require("../../../../generated/idl/CSSStyleProperties.js");
const CSSStyleRule = require("../../../../generated/idl/CSSStyleRule.js");
const attributes = require("../../attributes.js");
const { asciiLowercase } = require("../../helpers/strings.js");
const { evaluateMediaList } = require("../MediaList-impl.js");
const { parseStyleSheet } = require("./css-parser.js");
const { hasVarFunc, isGlobalKeyword } = require("./css-values.js");
const { absoluteFontSize } = require("./font-sizes.js");
const { systemColors } = require("./system-colors.js");
const defaultStyleSheet = fs.readFileSync(
path.resolve(__dirname, "../../../browser/default-stylesheet.css"),
{ encoding: "utf-8" }
);
let parsedDefaultStyleSheet;
function sourceProperty(style, property) {
if (style._getSpecifiedPropertyValue(property)) {
return property;
}
// A shorthand containing `var()` cannot be expanded when parsed, but still declares each
// of its longhands. Use the generated index to find these pending values independently.
for (const shorthand of shorthandsByLonghand.get(property) ?? []) {
if (hasVarFunc(style._getSpecifiedPropertyValue(shorthand))) {
return shorthand;
}
}
return undefined;
}
function* declaredLonghands(style) {
for (const name of style._propertyNames()) {
const longhands = longhandsByShorthand.get(name);
if (!longhands) {
yield name;
} else if (hasVarFunc(style._getSpecifiedPropertyValue(name))) {
yield* longhands;
}
}
}
// The document's style cache maps each element to a computed declaration. Declarations created here
// cascade properties on demand: they collect the style rules that might match the element once, then
// match and apply only the rules declaring the properties that are read. getComputedStyleDeclaration()
// replaces such a declaration with a complete one, built from the same rules.
function getCachedDeclaration(elementImpl) {
const styleCache = elementImpl._ownerDocument._styleCache;
let declaration = styleCache.get(elementImpl);
if (!declaration) {
declaration = createComputedDeclaration(elementImpl);
declaration._cascadedProperties = new Set();
styleCache.set(elementImpl, declaration);
}
return declaration;
}
function createComputedDeclaration(elementImpl) {
return CSSStyleProperties.createImpl(elementImpl._globalObject, [], {
computed: true,
ownerNode: elementImpl
});
}
// Like reading a property from getComputedStyle(), including CSSOM resolved-value adjustments, but
// only cascading the requested longhands.
function getComputedStylePropertyValue(elementImpl, property) {
return getCachedDeclaration(elementImpl).getPropertyValue(property);
}
function cascadeProperty(elementImpl, declaration, property) {
// Record absent declarations too, so they are not cascaded again.
declaration._cascadedProperties.add(property);
declaration._candidateStyles ??= collectCandidateStyles(elementImpl);
applyStyles(elementImpl, declaration, declaration._candidateStyles, property);
}
function getComputedStyleDeclaration(elementImpl) {
const styleCache = elementImpl._ownerDocument._styleCache;
const cachedDeclaration = styleCache.get(elementImpl);
const declaration = createComputedDeclaration(elementImpl);
if (cachedDeclaration?._cascadedProperties === null) {
declaration._copyDeclarationsFrom(cachedDeclaration);
} else {
// Build the complete declaration afresh rather than filling in the on-demand one, so that its
// enumeration order follows the cascade regardless of which properties were read earlier.
const candidateStyles = cachedDeclaration?._candidateStyles ?? collectCandidateStyles(elementImpl);
applyStyles(elementImpl, declaration, candidateStyles);
styleCache.set(elementImpl, declaration);
}
declaration._readonly = true;
return declaration;
}
// Applies the matching candidate styles, then the element's inline style, to the declaration. With
// `property`, only that longhand is applied, and only candidates declaring it are matched.
function applyStyles(elementImpl, declaration, candidateStyles, property) {
const specificities = new Map();
for (const candidate of candidateStyles) {
const { style } = candidate.rule;
if (property !== undefined && sourceProperty(style, property) === undefined) {
continue;
}
if (!matchCandidateStyle(candidate, elementImpl)) {
continue;
}
for (const name of property === undefined ? declaredLonghands(style) : [property]) {
handleProperty(name, declaration, style, specificities, candidate.specificity);
}
}
// Not every element implements `ElementCSSInlineStyle`.
const { style } = elementImpl;
if (style) {
for (const name of property === undefined ? declaredLonghands(style) : [property]) {
if (sourceProperty(style, name) !== undefined) {
handlePropertyForInlineStyle(name, declaration, style);
}
}
}
}
// Returns the styles of the rules that might match the element according to their selector subjects,
// in cascade order. Whether each rule actually matches is determined by matchCandidateStyle() when the
// rule's declarations are needed, since selector matching is the expensive part of the cascade.
function collectCandidateStyles(elementImpl) {
if (!parsedDefaultStyleSheet) {
// The parsed default stylesheet will be composed of CSSOM objects from the first global object accessed. This is a
// bit strange, but since we only ever access the internals of `parsedDefaultStyleSheet`, and don't expose it to
// callers, it shouldn't cause any issues.
parsedDefaultStyleSheet = parseStyleSheet(defaultStyleSheet, elementImpl._globalObject);
}
const candidateStyles = [];
collectCandidateStylesFrom(parsedDefaultStyleSheet, elementImpl, candidateStyles);
for (const sheetImpl of elementImpl._ownerDocument.styleSheets._list) {
collectCandidateStylesFrom(sheetImpl, elementImpl, candidateStyles);
}
return candidateStyles;
}
// `parentImpl` is a style sheet or a grouping rule; both expose `cssRules`.
function collectCandidateStylesFrom(parentImpl, elementImpl, candidateStyles) {
for (const ruleImpl of parentImpl.cssRules._list) {
if (CSSImportRule.isImpl(ruleImpl)) {
if (ruleImpl.styleSheet !== null && evaluateMediaList(ruleImpl.media._list)) {
collectCandidateStylesFrom(ruleImpl.styleSheet, elementImpl, candidateStyles);
}
} else if (CSSMediaRule.isImpl(ruleImpl)) {
if (evaluateMediaList(ruleImpl.media._list)) {
collectCandidateStylesFrom(ruleImpl, elementImpl, candidateStyles);
}
} else if (CSSStyleRule.isImpl(ruleImpl) && ruleMightMatchElement(ruleImpl, elementImpl)) {
candidateStyles.push({ rule: ruleImpl, matched: null, specificity: null });
}
}
}
function matchCandidateStyle(candidate, elementImpl) {
if (candidate.matched === null) {
const { ast, match } = matches(candidate.rule.selectorText, elementImpl);
candidate.matched = match;
if (match) {
// The specificity only depends on the selector AST, not on any individual property, so it is computed once per
// rule rather than once per property. This is a significant win for rules with many declarations.
candidate.specificity = Specificity.max(...Specificity.calculate(ast)).value;
}
}
return candidate.matched;
}
function handleProperty(property, declaration, style, specificities, specificity) {
// Source declarations have already parsed and expanded their shorthands. Copy their longhands
// directly; invoking CSSOM setters here would parse again and overwrite sibling cascade winners.
const source = sourceProperty(style, property);
// TODO: Substitute variables before computing a pending shorthand's longhands. Until then,
// these declarations are invalid at computed-value time, rather than absent from the cascade.
const value = source === property ? style._getSpecifiedPropertyValue(property) : "unset";
const priority = style.getPropertyPriority(source);
if (priority) {
declaration._setProperty(property, value, priority);
} else if (!declaration.getPropertyPriority(property)) {
if (specificities.has(property)) {
if (Specificity.compare(specificity, specificities.get(property)) >= 0) {
specificities.set(property, specificity);
declaration._setProperty(property, value);
}
} else {
specificities.set(property, specificity);
declaration._setProperty(property, value);
}
}
}
function handlePropertyForInlineStyle(property, declaration, style) {
const source = sourceProperty(style, property);
const value = source === property ? style._getSpecifiedPropertyValue(property) : "unset";
const priority = style.getPropertyPriority(source);
if (!declaration.getPropertyPriority(property) || priority) {
declaration._setProperty(property, value, priority);
}
}
function ruleMightMatchElement(ruleImpl, elementImpl) {
if (!ruleImpl._selectorSubjects) {
const domSelector = elementImpl._ownerDocument._getDOMSelector();
ruleImpl._selectorSubjects =
domSelector.extractSubjects(
ruleImpl.selectorText,
elementImpl._ownerDocument.contentType !== "text/html"
);
}
const subjects = ruleImpl._selectorSubjects;
if (subjects.length === 0) {
return true;
}
const id = elementImpl.getAttributeNS(null, "id");
const { classList } = elementImpl;
// TODO: Remove toLowerCase() after updating the dom-selector.
const tag = elementImpl._localName.toLowerCase();
for (const keys of subjects) {
let mightMatch = true;
if (keys.id && keys.id !== id) {
mightMatch = false;
} else if (keys.className && !classList.contains(keys.className)) {
mightMatch = false;
// TODO: Remove toLowerCase() after updating the dom-selector.
} else if (keys.tag && keys.tag.toLowerCase() !== tag) {
mightMatch = false;
} else if (keys.attr && attributes.getAttributeByName(elementImpl, keys.attr) === null) {
mightMatch = false;
}
if (mightMatch) {
return true;
}
}
return false;
}
function matches(selectorText, elementImpl) {
const domSelector = elementImpl._ownerDocument._getDOMSelector();
const { ast, match, pseudoElement } = domSelector.check(selectorText, elementImpl);
// `pseudoElement` is a pseudo-element selector (e.g. `::before`).
// However, we do not support getComputedStyle(element, pseudoElement), so `match` is set to `false`.
if (pseudoElement) {
return {
match: false
};
}
return { ast, match, pseudoElement };
}
function replaceEmptyValueAndKeywords(
property,
value,
elementImpl,
{ inherit, initial, isColor }
) {
if (value === "") {
if (!inherit || !elementImpl.parentElement) {
return initial;
}
value = getInheritedPropertyValue(property, elementImpl, { inherit, initial, isColor });
}
if (isGlobalKeyword(value)) {
value = replaceGlobalKeywords(property, value, elementImpl, { inherit, initial, isColor });
}
return value;
}
function getInheritedPropertyValue(property, elementImpl, { inherit, initial, isColor }) {
const parent = elementImpl.parentElement;
if (!parent) {
return initial;
}
if (!isColor) {
// Inheritance transfers the computed value, not the resolved value getPropertyValue() returns.
// The parent's computed value already accounts for inheritance, even when it is empty.
return getCachedDeclaration(parent)._getComputedPropertyValue(property);
}
// `@asamuzakjp/css-color` resolves inherited specified colors, so find the nearest ancestor that
// specifies one.
for (let ancestor = parent; ancestor; ancestor = ancestor.parentElement) {
const declaration = getCachedDeclaration(ancestor);
let value = declaration._getSpecifiedPropertyValue(property);
// If the value is a system color value, retrieve it again as a computed value.
if (value && systemColors.has(asciiLowercase(value))) {
value = declaration.getPropertyValue(property);
}
if (value) {
if (isGlobalKeyword(value)) {
return replaceGlobalKeywords(property, value, ancestor, { inherit, initial, isColor });
}
return value;
}
if (!inherit) {
break;
}
}
return initial;
}
function replaceGlobalKeywords(property, value, elementImpl, { inherit, initial, isColor }) {
let element = elementImpl;
while (element) {
switch (value) {
case "initial": {
return initial;
}
case "inherit": {
if (!element.parentElement) {
return initial;
}
value = getInheritedPropertyValue(property, element, { inherit, initial, isColor });
break;
}
case "unset": {
if (!inherit || !element.parentElement) {
return initial;
}
value = getInheritedPropertyValue(property, element, { inherit, initial, isColor });
break;
}
case "revert-layer": {
// TODO: https://drafts.csswg.org/css-cascade-5/#revert-layer
return value;
}
case "revert": {
// TODO: https://drafts.csswg.org/css-cascade-5/#default
return value;
}
default: {
// fall through; value is not a CSS-wide keyword.
}
}
if (element.parentElement) {
if (!value) {
element = element.parentElement;
} else if (isGlobalKeyword(value)) {
return replaceGlobalKeywords(property, value, element, { inherit, initial, isColor });
} else {
return value;
}
} else {
return initial;
}
}
return value;
}
function getParentFontSizeInPixels(elementImpl) {
const fontSize = getComputedStylePropertyValue(elementImpl.parentElement, "font-size");
if (absoluteFontSize.has(fontSize)) {
return absoluteFontSize.get(fontSize).px;
}
const parsed = parseFloat(fontSize);
if (!Number.isNaN(parsed)) {
return parsed;
}
// Fallback to initial font-size (medium)
return absoluteFontSize.get("medium").px;
}
exports.SHADOW_DOM_PSEUDO_REGEXP = /^::(?:part|slotted)\(/i;
exports.cascadeProperty = cascadeProperty;
exports.getComputedStylePropertyValue = getComputedStylePropertyValue;
exports.getComputedStyleDeclaration = getComputedStyleDeclaration;
exports.getInheritedPropertyValue = getInheritedPropertyValue;
exports.getParentFontSizeInPixels = getParentFontSizeInPixels;
exports.replaceEmptyValueAndKeywords = replaceEmptyValueAndKeywords;