diff --git a/.changeset/chilled-elephants-kneel.md b/.changeset/chilled-elephants-kneel.md new file mode 100644 index 0000000..f109322 --- /dev/null +++ b/.changeset/chilled-elephants-kneel.md @@ -0,0 +1,5 @@ +--- +"@acemir/cssom": patch +--- + +feat: support CSSPageRule and implementation improvements diff --git a/helpers/utils.js b/helpers/utils.js index 4fd2bfe..26be9f1 100644 --- a/helpers/utils.js +++ b/helpers/utils.js @@ -29,13 +29,41 @@ function getObjectKeysWithGetters(object) { var keys = Object.keys(object); // Filter out specific __ prefixed properties that have getter equivalents - var hiddenProperties = ['__parentRule', '__parentStyleSheet', '__selectorText', '__style']; + var hiddenProperties = [ + '__conditionText', + '__href', + '__layerName', + '__media', + '__namespaceURI', + '__parentRule', + '__parentStyleSheet', + '__prefix', + '__selectorText', + '__style', + '__styleSheet', + '__supportsText' + ]; keys = keys.filter(function(key) { return hiddenProperties.indexOf(key) === -1; }); // Add prototype getters for CSSOM objects - var prototypeGetters = ['parentRule', 'parentStyleSheet', 'selectorText', 'style']; + var prototypeGetters = [ + 'conditionText', + 'containerName', + 'containerQuery', + 'href', + 'layerName', + 'media', + 'namespaceURI', + 'parentRule', + 'parentStyleSheet', + 'prefix', + 'selectorText', + 'style', + 'styleSheet', + 'supportsText' + ]; for (var i = 0; i < prototypeGetters.length; i++) { var prop = prototypeGetters[i]; // Check if the property exists as a getter on the prototype chain @@ -71,7 +99,22 @@ function materializeGetters(object, stack) { // Add current object to stack stack.push(object); - var prototypeGetters = ['parentRule', 'parentStyleSheet', 'selectorText', 'style']; + var prototypeGetters = [ + 'conditionText', + 'containerName', + 'containerQuery', + 'href', + 'layerName', + 'media', + 'namespaceURI', + 'parentRule', + 'parentStyleSheet', + 'prefix', + 'selectorText', + 'style', + 'styleSheet', + 'supportsText' + ]; for (var i = 0; i < prototypeGetters.length; i++) { var prop = prototypeGetters[i]; if (!object.hasOwnProperty(prop) && hasPrototypeGetter(object, prop)) { diff --git a/lib/CSSConditionRule.js b/lib/CSSConditionRule.js index 3a4fa1e..9c55369 100644 --- a/lib/CSSConditionRule.js +++ b/lib/CSSConditionRule.js @@ -13,12 +13,17 @@ var CSSOM = { */ CSSOM.CSSConditionRule = function CSSConditionRule() { CSSOM.CSSGroupingRule.call(this); + this.__conditionText = ''; }; CSSOM.CSSConditionRule.prototype = new CSSOM.CSSGroupingRule(); CSSOM.CSSConditionRule.prototype.constructor = CSSOM.CSSConditionRule; -CSSOM.CSSConditionRule.prototype.conditionText = '' -CSSOM.CSSConditionRule.prototype.cssText = '' + +Object.defineProperty(CSSOM.CSSConditionRule.prototype, "conditionText", { + get: function () { + return this.__conditionText; + } +}); //.CommonJS exports.CSSConditionRule = CSSOM.CSSConditionRule; diff --git a/lib/CSSContainerRule.js b/lib/CSSContainerRule.js index 9f3bd3d..07942ac 100644 --- a/lib/CSSContainerRule.js +++ b/lib/CSSContainerRule.js @@ -22,27 +22,35 @@ CSSOM.CSSContainerRule.prototype.constructor = CSSOM.CSSContainerRule; CSSOM.CSSContainerRule.prototype.type = 17; Object.defineProperties(CSSOM.CSSContainerRule.prototype, { - "conditionText": { - get: function() { - return this.containerText; - }, - set: function(value) { - this.containerText = value; - }, - configurable: true, - enumerable: true - }, "cssText": { get: function() { var cssTexts = []; for (var i=0, length=this.cssRules.length; i < length; i++) { cssTexts.push(this.cssRules[i].cssText); } - return "@container " + this.containerText + " {" + (cssTexts.length ? "\n " + cssTexts.join("\n ") : "") + "\n}"; + return "@container " + this.conditionText + " {" + (cssTexts.length ? "\n " + cssTexts.join("\n ") : "") + "\n}"; }, configurable: true, enumerable: true - } + }, + "containerName": { + get: function() { + var parts = this.conditionText.trim().split(/\s+/); + if (parts.length > 1 && parts[0] !== '(' && !parts[0].startsWith('(')) { + return parts[0]; + } + return ""; + } + }, + "containerQuery": { + get: function() { + var parts = this.conditionText.trim().split(/\s+/); + if (parts.length > 1 && parts[0] !== '(' && !parts[0].startsWith('(')) { + return parts.slice(1).join(' '); + } + return this.conditionText; + } + }, }); diff --git a/lib/CSSImportRule.js b/lib/CSSImportRule.js index b216042..ac11cfe 100644 --- a/lib/CSSImportRule.js +++ b/lib/CSSImportRule.js @@ -14,21 +14,25 @@ var CSSOM = { */ CSSOM.CSSImportRule = function CSSImportRule() { CSSOM.CSSRule.call(this); - this.href = ""; - this.media = new CSSOM.MediaList(); - this.layerName = null; - this.supportsText = null; - this.styleSheet = new CSSOM.CSSStyleSheet(); + this.__href = ""; + this.__media = new CSSOM.MediaList(); + this.__layerName = null; + this.__supportsText = null; + this.__styleSheet = new CSSOM.CSSStyleSheet(); }; CSSOM.CSSImportRule.prototype = new CSSOM.CSSRule(); CSSOM.CSSImportRule.prototype.constructor = CSSOM.CSSImportRule; -CSSOM.CSSImportRule.prototype.type = 3; + +Object.defineProperty(CSSOM.CSSImportRule.prototype, "type", { + value: 3, + writable: false +}); Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { get: function() { var mediaText = this.media.mediaText; - return "@import url(\"" + this.href + "\")" + (this.layerName !== null ? " layer" + (this.layerName && "(" + this.layerName + ")") : "" ) + (this.supportsText ? " supports(" + this.supportsText + ")" : "" ) + (mediaText ? " " + mediaText : "") + ";"; + return "@import url(\"" + this.href.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + "\")" + (this.layerName !== null ? " layer" + (this.layerName && "(" + this.layerName + ")") : "" ) + (this.supportsText ? " supports(" + this.supportsText + ")" : "" ) + (mediaText ? " " + mediaText : "") + ";"; }, set: function(cssText) { var i = 0; @@ -47,8 +51,40 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { var layerRegExp = /layer\(([^)]*)\)/; var layerRuleNameRegExp = /^(-?[_a-zA-Z]+(\.[_a-zA-Z]+)*[_a-zA-Z0-9-]*)$/; - var supportsRegExp = /supports\(([^)]+)\)/; var doubleOrMoreSpacesRegExp = /\s{2,}/g; + + /** + * Extracts the content inside supports() handling nested parentheses. + * @param {string} text - The text to parse + * @returns {object|null} - {content: string, endIndex: number} or null if not found + */ + function extractSupportsContent(text) { + var supportsIndex = text.indexOf('supports('); + if (supportsIndex !== 0) { + return null; + } + + var depth = 0; + var start = supportsIndex + 'supports('.length; + var i = start; + + for (; i < text.length; i++) { + if (text[i] === '(') { + depth++; + } else if (text[i] === ')') { + if (depth === 0) { + // Found the closing parenthesis for supports() + return { + content: text.slice(start, i), + endIndex: i + }; + } + depth--; + } + } + + return null; // Unbalanced parentheses + } for (var character; (character = cssText.charAt(i)); i++) { @@ -89,7 +125,7 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { url = url.slice(1, -1); } } - this.href = url; + this.__href = url; i = index; state = 'media'; } @@ -101,7 +137,7 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { if (!index) { throw i + ": '\"' not found"; } - this.href = cssText.slice(i + 1, index); + this.__href = cssText.slice(i + 1, index); i = index; state = 'media'; } @@ -113,7 +149,7 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { if (!index) { throw i + ': "\'" not found'; } - this.href = cssText.slice(i + 1, index); + this.__href = cssText.slice(i + 1, index); i = index; state = 'media'; } @@ -131,7 +167,7 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { var layerName = layerMatch[1].trim(); if (layerName.match(layerRuleNameRegExp) !== null) { - this.layerName = layerMatch[1].trim(); + this.__layerName = layerMatch[1].trim(); bufferTrimmed = bufferTrimmed.replace(layerRegExp, '') .replace(doubleOrMoreSpacesRegExp, ' ') // Replace double or more spaces with single space .trim(); @@ -144,19 +180,19 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { } } } else { - this.layerName = ""; + this.__layerName = ""; bufferTrimmed = bufferTrimmed.substring('layer'.length).trim() } } - var supportsMatch = bufferTrimmed.match(supportsRegExp); + var supportsResult = extractSupportsContent(bufferTrimmed); - if (supportsMatch && supportsMatch.index === 0) { + if (supportsResult) { // REVIEW: In the browser, an empty supports() invalidates and ignores the entire @import rule - this.supportsText = supportsMatch[1].trim(); - bufferTrimmed = bufferTrimmed.replace(supportsRegExp, '') - .replace(doubleOrMoreSpacesRegExp, ' ') // Replace double or more spaces with single space - .trim(); + this.__supportsText = supportsResult.content.trim(); + // Remove the entire supports(...) from the buffer + bufferTrimmed = bufferTrimmed.slice(0, 0) + bufferTrimmed.slice(supportsResult.endIndex + 1); + bufferTrimmed = bufferTrimmed.replace(doubleOrMoreSpacesRegExp, ' ').trim(); } // REVIEW: In the browser, any invalid media is replaced with 'not all' @@ -177,6 +213,43 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", { } }); +Object.defineProperty(CSSOM.CSSImportRule.prototype, "href", { + get: function() { + return this.__href; + } +}); + +Object.defineProperty(CSSOM.CSSImportRule.prototype, "media", { + get: function() { + return this.__media; + }, + set: function(value) { + if (typeof value === "string") { + this.__media.mediaText = value; + } else { + this.__media = value; + } + } +}); + +Object.defineProperty(CSSOM.CSSImportRule.prototype, "layerName", { + get: function() { + return this.__layerName; + } +}); + +Object.defineProperty(CSSOM.CSSImportRule.prototype, "supportsText", { + get: function() { + return this.__supportsText; + } +}); + +Object.defineProperty(CSSOM.CSSImportRule.prototype, "styleSheet", { + get: function() { + return this.__styleSheet; + } +}); + //.CommonJS exports.CSSImportRule = CSSOM.CSSImportRule; diff --git a/lib/CSSMediaRule.js b/lib/CSSMediaRule.js index b6f44d5..d7ef305 100644 --- a/lib/CSSMediaRule.js +++ b/lib/CSSMediaRule.js @@ -16,7 +16,7 @@ var CSSOM = { */ CSSOM.CSSMediaRule = function CSSMediaRule() { CSSOM.CSSConditionRule.call(this); - this.media = new CSSOM.MediaList(); + this.__media = new CSSOM.MediaList(); }; CSSOM.CSSMediaRule.prototype = new CSSOM.CSSConditionRule(); @@ -25,16 +25,24 @@ CSSOM.CSSMediaRule.prototype.type = 4; // https://opensource.apple.com/source/WebCore/WebCore-7611.1.21.161.3/css/CSSMediaRule.cpp Object.defineProperties(CSSOM.CSSMediaRule.prototype, { - "conditionText": { + "media": { get: function() { - return this.media.mediaText; + return this.__media; }, set: function(value) { - this.media.mediaText = value; + if (typeof value === "string") { + this.__media.mediaText = value; + } else { + this.__media = value; + } }, - configurable: true, enumerable: true }, + "conditionText": { + get: function() { + return this.media.mediaText; + } + }, "cssText": { get: function() { var cssTexts = []; diff --git a/lib/CSSNamespaceRule.js b/lib/CSSNamespaceRule.js index 550189f..19d9fea 100644 --- a/lib/CSSNamespaceRule.js +++ b/lib/CSSNamespaceRule.js @@ -12,23 +12,25 @@ var CSSOM = { */ CSSOM.CSSNamespaceRule = function CSSNamespaceRule() { CSSOM.CSSRule.call(this); - this.prefix = ""; - this.namespaceURI = ""; - this.styleSheet = new CSSOM.CSSStyleSheet(); + this.__prefix = ""; + this.__namespaceURI = ""; }; CSSOM.CSSNamespaceRule.prototype = new CSSOM.CSSRule(); CSSOM.CSSNamespaceRule.prototype.constructor = CSSOM.CSSNamespaceRule; -CSSOM.CSSNamespaceRule.prototype.type = 10; + +Object.defineProperty(CSSOM.CSSNamespaceRule.prototype, "type", { + value: 10, + writable: false +}); Object.defineProperty(CSSOM.CSSNamespaceRule.prototype, "cssText", { get: function() { return "@namespace" + (this.prefix && " " + this.prefix) + " url(\"" + this.namespaceURI + "\");"; }, set: function(cssText) { - // Reset prefix and namespaceURI - this.prefix = ""; - this.namespaceURI = ""; + var newPrefix = ""; + var newNamespaceURI = ""; // Remove @namespace and trim var text = cssText.trim(); @@ -51,26 +53,40 @@ Object.defineProperty(CSSOM.CSSNamespaceRule.prototype, "cssText", { if (match) { // If prefix is present if (match[1]) { - this.prefix = match[1]; + newPrefix = match[1]; } // If url(...) form with quotes if (typeof match[3] !== "undefined") { - this.namespaceURI = match[3]; + newNamespaceURI = match[3]; } // If url(...) form without quotes else if (typeof match[4] !== "undefined") { - this.namespaceURI = match[4].trim(); + newNamespaceURI = match[4].trim(); } // If quoted string form else if (typeof match[6] !== "undefined") { - this.namespaceURI = match[6]; + newNamespaceURI = match[6]; } + + this.__prefix = newPrefix; + this.__namespaceURI = newNamespaceURI; } else { throw new DOMException("Invalid @namespace rule", "InvalidStateError"); } } }); +Object.defineProperty(CSSOM.CSSNamespaceRule.prototype, "prefix", { + get: function() { + return this.__prefix; + } +}); + +Object.defineProperty(CSSOM.CSSNamespaceRule.prototype, "namespaceURI", { + get: function() { + return this.__namespaceURI; + } +}); //.CommonJS exports.CSSNamespaceRule = CSSOM.CSSNamespaceRule; diff --git a/lib/CSSPageRule.js b/lib/CSSPageRule.js new file mode 100644 index 0000000..7ada98b --- /dev/null +++ b/lib/CSSPageRule.js @@ -0,0 +1,275 @@ +//.CommonJS +var CSSOM = { + CSSStyleDeclaration: require("./CSSStyleDeclaration").CSSStyleDeclaration, + CSSRule: require("./CSSRule").CSSRule, + CSSRuleList: require("./CSSRuleList").CSSRuleList, + CSSGroupingRule: require("./CSSGroupingRule").CSSGroupingRule, +}; +// Use cssstyle if available +try { + CSSOM.CSSStyleDeclaration = require("cssstyle").CSSStyleDeclaration; +} catch (e) { + // ignore +} +///CommonJS + + +/** + * @constructor + * @see https://drafts.csswg.org/cssom/#the-csspagerule-interface + */ +CSSOM.CSSPageRule = function CSSPageRule() { + CSSOM.CSSGroupingRule.call(this); + this.__style = new CSSOM.CSSStyleDeclaration(); + this.__style.parentRule = this; +}; + +CSSOM.CSSPageRule.prototype = new CSSOM.CSSGroupingRule(); +CSSOM.CSSPageRule.prototype.constructor = CSSOM.CSSPageRule; + +Object.defineProperty(CSSOM.CSSPageRule.prototype, "type", { + value: 6, + writable: false +}); + +Object.defineProperty(CSSOM.CSSPageRule.prototype, "selectorText", { + get: function() { + return this.__selectorText; + }, + set: function(value) { + if (typeof value === "string") { + var trimmedValue = value.trim(); + + // Empty selector is valid for @page + if (trimmedValue === '') { + this.__selectorText = ''; + return; + } + + // Parse @page selectorText for page name and pseudo-pages + // Valid formats: + // - (empty - no name, no pseudo-page) + // - :left, :right, :first, :blank (pseudo-page only) + // - named (named page only) + // - named:first (named page with single pseudo-page) + // - named:first:left (named page with multiple pseudo-pages) + var atPageRuleSelectorRegExp = /^([^\s:]+)?((?::\w+)*)$/; + var match = trimmedValue.match(atPageRuleSelectorRegExp); + if (match) { + var pageName = match[1] || ''; + var pseudoPages = match[2] || ''; + + // Validate page name if present + if (pageName) { + var cssCustomIdentifierRegExp = /^(-?[_a-zA-Z]+(\.[_a-zA-Z]+)*[_a-zA-Z0-9-]*)$/; // Validates a css custom identifier + // Page name can be an identifier or a string + if (!cssCustomIdentifierRegExp.test(pageName)) { + return; + } + } + + // Validate pseudo-pages if present + if (pseudoPages) { + var pseudos = pseudoPages.split(':').filter(function(p) { return p; }); + var validPseudos = ['left', 'right', 'first', 'blank']; + var allValid = true; + for (var j = 0; j < pseudos.length; j++) { + if (validPseudos.indexOf(pseudos[j].toLowerCase()) === -1) { + allValid = false; + break; + } + } + + if (!allValid) { + return; // Invalid pseudo-page, do nothing + } + } + + this.__selectorText = pageName + pseudoPages.toLowerCase(); + } + } + } +}); + +Object.defineProperty(CSSOM.CSSPageRule.prototype, "style", { + get: function() { + return this.__style; + }, + set: function(value) { + if (typeof value === "string") { + this.__style.cssText = value; + } else { + this.__style = value; + } + } +}); + +Object.defineProperty(CSSOM.CSSPageRule.prototype, "cssText", { + get: function() { + var values = "" + if (this.cssRules.length) { + var valuesArr = [" {"]; + this.style.cssText && valuesArr.push(this.style.cssText); + valuesArr.push(this.cssRules.map(function(rule){ return rule.cssText }).join("\n ")); + values = valuesArr.join("\n ") + "\n}" + } else { + values = " {" + (this.style.cssText ? " " + this.style.cssText : "") + " }"; + } + return "@page" + (this.selectorText ? " " + this.selectorText : "") + values; + }, + set: function(cssText) { + if (typeof value === "string") { + var rule = CSSOM.CSSPageRule.parse(cssText); + this.__style = rule.style; + this.selectorText = rule.selectorText; + } + } +}); + +/** + * NON-STANDARD + * lightweight version of parse.js. + * @param {string} ruleText + * @return CSSPageRule + */ +CSSOM.CSSPageRule.parse = function(ruleText) { + var i = 0; + var state = "selector"; + var index; + var j = i; + var buffer = ""; + + var SIGNIFICANT_WHITESPACE = { + "selector": true, + "value": true + }; + + var pageRule = new CSSOM.CSSPageRule(); + var name, priority=""; + + for (var character; (character = ruleText.charAt(i)); i++) { + + switch (character) { + + case " ": + case "\t": + case "\r": + case "\n": + case "\f": + if (SIGNIFICANT_WHITESPACE[state]) { + // Squash 2 or more white-spaces in the row into 1 + switch (ruleText.charAt(i - 1)) { + case " ": + case "\t": + case "\r": + case "\n": + case "\f": + break; + default: + buffer += " "; + break; + } + } + break; + + // String + case '"': + j = i + 1; + index = ruleText.indexOf('"', j) + 1; + if (!index) { + throw '" is missing'; + } + buffer += ruleText.slice(i, index); + i = index - 1; + break; + + case "'": + j = i + 1; + index = ruleText.indexOf("'", j) + 1; + if (!index) { + throw "' is missing"; + } + buffer += ruleText.slice(i, index); + i = index - 1; + break; + + // Comment + case "/": + if (ruleText.charAt(i + 1) === "*") { + i += 2; + index = ruleText.indexOf("*/", i); + if (index === -1) { + throw new SyntaxError("Missing */"); + } else { + i = index + 1; + } + } else { + buffer += character; + } + break; + + case "{": + if (state === "selector") { + pageRule.selectorText = buffer.trim(); + buffer = ""; + state = "name"; + } + break; + + case ":": + if (state === "name") { + name = buffer.trim(); + buffer = ""; + state = "value"; + } else { + buffer += character; + } + break; + + case "!": + if (state === "value" && ruleText.indexOf("!important", i) === i) { + priority = "important"; + i += "important".length; + } else { + buffer += character; + } + break; + + case ";": + if (state === "value") { + pageRule.style.setProperty(name, buffer.trim(), priority); + priority = ""; + buffer = ""; + state = "name"; + } else { + buffer += character; + } + break; + + case "}": + if (state === "value") { + pageRule.style.setProperty(name, buffer.trim(), priority); + priority = ""; + buffer = ""; + } else if (state === "name") { + break; + } else { + buffer += character; + } + state = "selector"; + break; + + default: + buffer += character; + break; + + } + } + + return pageRule; + +}; + +//.CommonJS +exports.CSSPageRule = CSSOM.CSSPageRule; +///CommonJS diff --git a/lib/CSSRule.js b/lib/CSSRule.js index a86f87e..0be58c9 100644 --- a/lib/CSSRule.js +++ b/lib/CSSRule.js @@ -37,6 +37,12 @@ Object.defineProperties(CSSOM.CSSRule.prototype, { constructor: { value: CSSOM.CSSRule }, + cssRule: { + value: "", + configurable: true, + enumerable: true + }, + parentRule: { get: function() { return this.__parentRule diff --git a/lib/CSSStyleRule.js b/lib/CSSStyleRule.js index 2163444..74cc463 100644 --- a/lib/CSSStyleRule.js +++ b/lib/CSSStyleRule.js @@ -39,7 +39,20 @@ Object.defineProperty(CSSOM.CSSStyleRule.prototype, "selectorText", { return this.__selectorText; }, set: function(value) { - this.__selectorText = value; + if (typeof value === "string") { + var trimmedValue = value.trim(); + + if (trimmedValue === '') { + return; + } + + // TODO: Setting invalid selectorText should be ignored + // There are some validations already on lib/parse.js + // but the same validations should be applied here. + // Check if we can move these validation logic to a shared function. + + this.__selectorText = trimmedValue; + } } }); @@ -76,9 +89,11 @@ Object.defineProperty(CSSOM.CSSStyleRule.prototype, "cssText", { return text; }, set: function(cssText) { - var rule = CSSOM.CSSStyleRule.parse(cssText); - this.__style = rule.style; - this.__selectorText = rule.selectorText; + if (typeof cssText === "string") { + var rule = CSSOM.CSSStyleRule.parse(cssText); + this.__style = rule.style; + this.selectorText = rule.selectorText; + } } }); diff --git a/lib/CSSStyleSheet.js b/lib/CSSStyleSheet.js index 55abf5f..b3a995f 100644 --- a/lib/CSSStyleSheet.js +++ b/lib/CSSStyleSheet.js @@ -1,5 +1,6 @@ //.CommonJS var CSSOM = { + MediaList: require("./MediaList").MediaList, StyleSheet: require("./StyleSheet").StyleSheet, CSSRuleList: require("./CSSRuleList").CSSRuleList, CSSStyleRule: require("./CSSStyleRule").CSSStyleRule, @@ -59,7 +60,10 @@ CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) { } var ruleToParse = String(rule); - var parsedSheet = CSSOM.parse(ruleToParse); + var parseErrors = []; + var parsedSheet = CSSOM.parse(ruleToParse, undefined, function(err) { + parseErrors.push(err); + } ); if (parsedSheet.cssRules.length !== 1) { errorUtils.throwParseError(this, 'insertRule', this.constructor.name, ruleToParse, 'SyntaxError'); } @@ -127,12 +131,21 @@ CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) { 'HierarchyRequestError'); } + // Cannot insert if there are already non-special rules + if (firstNonImportNamespaceIndex < this.cssRules.length) { + errorUtils.throwError(this, 'DOMException', + "Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.", + 'InvalidStateError'); + } + // Cannot insert after other types of rules if (index > firstNonImportNamespaceIndex) { errorUtils.throwError(this, 'DOMException', "Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.", 'HierarchyRequestError'); } + + } else if (cssRule.constructor.name === 'CSSLayerStatementRule') { // @layer statement rules can be inserted anywhere before @import and @namespace // No additional restrictions beyond what's already handled @@ -149,6 +162,10 @@ CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) { "Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.", 'HierarchyRequestError'); } + + if (parseErrors.length !== 0) { + errorUtils.throwParseError(this, 'insertRule', this.constructor.name, ruleToParse, 'SyntaxError'); + } } cssRule.__parentStyleSheet = this; @@ -188,13 +205,20 @@ CSSOM.CSSStyleSheet.prototype.deleteRule = function(index) { if (index >= this.cssRules.length) { errorUtils.throwIndexError(this, 'deleteRule', this.constructor.name, index, this.cssRules.length); } - if (this.cssRules[index] && this.cssRules[index].constructor.name == "CSSNamespaceRule") { - var shouldContinue = this.cssRules.every(function (rule) { - return ['CSSImportRule','CSSLayerStatementRule','CSSNamespaceRule'].indexOf(rule.constructor.name) !== -1 - }); - if (!shouldContinue) { - errorUtils.throwError(this, 'DOMException', "Failed to execute 'deleteRule' on '" + this.constructor.name + "': Failed to delete rule.", "InvalidStateError"); + if (this.cssRules[index]) { + if (this.cssRules[index].constructor.name == "CSSNamespaceRule") { + var shouldContinue = this.cssRules.every(function (rule) { + return ['CSSImportRule','CSSLayerStatementRule','CSSNamespaceRule'].indexOf(rule.constructor.name) !== -1 + }); + if (!shouldContinue) { + errorUtils.throwError(this, 'DOMException', "Failed to execute 'deleteRule' on '" + this.constructor.name + "': Failed to delete rule.", "InvalidStateError"); + } } + if (this.cssRules[index].constructor.name == "CSSImportRule") { + this.cssRules[index].styleSheet.__parentStyleSheet = null; + } + + this.cssRules[index].__parentStyleSheet = null; } this.cssRules.splice(index, 1); }; diff --git a/lib/StyleSheet.js b/lib/StyleSheet.js index 3ca7b23..f0f1038 100644 --- a/lib/StyleSheet.js +++ b/lib/StyleSheet.js @@ -1,5 +1,7 @@ //.CommonJS -var CSSOM = {}; +var CSSOM = { + MediaList: require("./MediaList").MediaList +}; ///CommonJS @@ -8,10 +10,23 @@ var CSSOM = {}; * @see http://dev.w3.org/csswg/cssom/#the-stylesheet-interface */ CSSOM.StyleSheet = function StyleSheet() { + this.__media = new CSSOM.MediaList(); this.__parentStyleSheet = null; }; Object.defineProperties(CSSOM.StyleSheet.prototype, { + media: { + get: function() { + return this.__media; + }, + set: function(value) { + if (typeof value === "string") { + this.__media.mediaText = value; + } else { + this.__media = value; + } + } + }, parentStyleSheet: { get: function() { return this.__parentStyleSheet; diff --git a/lib/index.js b/lib/index.js index 39d18ea..c8e895e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -33,5 +33,6 @@ exports.CSSValueExpression = require('./CSSValueExpression').CSSValueExpression; exports.CSSScopeRule = require('./CSSScopeRule').CSSScopeRule; exports.CSSLayerBlockRule = require('./CSSLayerBlockRule').CSSLayerBlockRule; exports.CSSLayerStatementRule = require('./CSSLayerStatementRule').CSSLayerStatementRule; +exports.CSSPageRule = require('./CSSPageRule').CSSPageRule; exports.parse = require('./parse').parse; exports.clone = require('./clone').clone; diff --git a/lib/parse.js b/lib/parse.js index bacb041..5133479 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -51,7 +51,8 @@ CSSOM.parse = function parse(token, opts, errorHandler) { "counterStyleBlock": true, 'documentRule-begin': true, "scopeBlock": true, - "layerBlock": true + "layerBlock": true, + "pageBlock": true }; var styleSheet = new CSSOM.CSSStyleSheet(); @@ -69,7 +70,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { var ancestorRules = []; var prevScope; - var name, priority="", styleRule, mediaRule, containerRule, counterStyleRule, supportsRule, importRule, fontFaceRule, keyframesRule, documentRule, hostRule, startingStyleRule, scopeRule, layerBlockRule, layerStatementRule, nestedSelectorRule, namespaceRule; + var name, priority="", styleRule, mediaRule, containerRule, counterStyleRule, supportsRule, importRule, fontFaceRule, keyframesRule, documentRule, hostRule, startingStyleRule, scopeRule, pageRule, layerBlockRule, layerStatementRule, nestedSelectorRule, namespaceRule; // Track defined namespace prefixes for validation var definedNamespacePrefixes = {}; @@ -83,8 +84,9 @@ CSSOM.parse = function parse(token, opts, errorHandler) { var forwardImportRuleValidationRegExp = /(?:\s|\/\*|'|")/; // Match that the rule is followed by any whitespace, an opening comment, a single quote or double quote var forwardRuleClosingBraceRegExp = /{[^{}]*}|}/; // Finds the next closing brace of a rule block var forwardRuleSemicolonAndOpeningBraceRegExp = /^.*?({|;)/; // Finds the next semicolon or opening brace after the at-rule - var layerRuleNameRegExp = /^(-?[_a-zA-Z]+(\.[_a-zA-Z]+)*[_a-zA-Z0-9-]*)$/; // Validates a single @layer name + var cssCustomIdentifierRegExp = /^(-?[_a-zA-Z]+(\.[_a-zA-Z]+)*[_a-zA-Z0-9-]*)$/; // Validates a css custom identifier var startsWithCombinatorRegExp = /^\s*[>+~]/; // Checks if a selector starts with a CSS combinator (>, +, ~) + var atPageRuleSelectorRegExp = /^([^\s:]+)?((?::\w+)*)$/; /** * Searches for the first occurrence of a CSS at-rule statement terminator (`;` or `}`) @@ -409,12 +411,12 @@ CSSOM.parse = function parse(token, opts, errorHandler) { if (isValid && atRuleKey === "@scope") { var openBraceIndex = ruleSlice.indexOf('{'); if (openBraceIndex !== -1) { - // Extract the scope prelude (everything between @scope and {) - var scopePrelude = ruleSlice.slice(0, openBraceIndex).trim(); - - // Skip past '@scope' keyword and whitespace - var preludeContent = scopePrelude.slice(6).trim(); - + // Extract the rule prelude (everything between the at-rule and {) + var rulePrelude = ruleSlice.slice(0, openBraceIndex).trim(); + + // Skip past at-rule keyword and whitespace + var preludeContent = rulePrelude.slice("@scope".length).trim(); + if (preludeContent.length > 0) { // Parse the scope prelude var parsedScopePrelude = parseScopePrelude(preludeContent); @@ -453,6 +455,64 @@ CSSOM.parse = function parse(token, opts, errorHandler) { // Empty prelude (@scope {}) is valid } } + + if (isValid && atRuleKey === "@page") { + var openBraceIndex = ruleSlice.indexOf('{'); + if (openBraceIndex !== -1) { + // Extract the rule prelude (everything between the at-rule and {) + var rulePrelude = ruleSlice.slice(0, openBraceIndex).trim(); + + // Skip past at-rule keyword and whitespace + var preludeContent = rulePrelude.slice("@page".length).trim(); + + if (preludeContent.length > 0) { + var trimmedValue = preludeContent.trim(); + + // Empty selector is valid for @page + if (trimmedValue !== '') { + // Parse @page selectorText for page name and pseudo-pages + // Valid formats: + // - (empty - no name, no pseudo-page) + // - :left, :right, :first, :blank (pseudo-page only) + // - named (named page only) + // - named:first (named page with single pseudo-page) + // - named:first:left (named page with multiple pseudo-pages) + var match = trimmedValue.match(atPageRuleSelectorRegExp); + if (match) { + var pageName = match[1] || ''; + var pseudoPages = match[2] || ''; + + // Validate page name if present + if (pageName) { + if (!cssCustomIdentifierRegExp.test(pageName)) { + isValid = false; + } + } + + // Validate pseudo-pages if present + if (pseudoPages) { + var pseudos = pseudoPages.split(':').filter(function(p) { return p; }); + var validPseudos = ['left', 'right', 'first', 'blank']; + var allValid = true; + for (var j = 0; j < pseudos.length; j++) { + if (validPseudos.indexOf(pseudos[j].toLowerCase()) === -1) { + allValid = false; + break; + } + } + + if (!allValid) { + isValid = false; + } + } + } else { + isValid = false; + } + } + + } + } + } if (!isValid) { // If it's invalid the browser will simply ignore the entire invalid block @@ -532,6 +592,27 @@ CSSOM.parse = function parse(token, opts, errorHandler) { return false; } + // Check for invalid pseudo-class usage with quoted strings + // Pseudo-classes like :lang(), :dir(), :nth-*() should not accept quoted strings + var pseudoPattern = /::?([a-zA-Z][\w-]*)\(([^)]+)\)/g; + var pseudoMatch; + while ((pseudoMatch = pseudoPattern.exec(selector)) !== null) { + var pseudoName = pseudoMatch[1]; + var pseudoContent = pseudoMatch[2]; + + // List of pseudo-classes that should not accept quoted strings + // :lang() - accepts language codes: en, fr-CA + // :dir() - accepts direction: ltr, rtl + // :nth-*() - accepts An+B notation: 2n+1, odd, even + var noQuotesPseudos = ['lang', 'dir', 'nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type']; + + for (var i = 0; i < noQuotesPseudos.length; i++) { + if (pseudoName === noQuotesPseudos[i] && /['"]/.test(pseudoContent)) { + return false; + } + } + } + // Fallback to a loose regexp for the overall selector structure (without deep paren matching) // This is similar to the original, but without nested paren limitations // Modified to support namespace selectors: *|element, prefix|element, |element @@ -636,7 +717,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { * @returns {boolean} Returns `true` if the selector is valid, otherwise `false`. */ - // Cache to store validated selectors (ES5-compliant object) + // Cache to store validated selectors (previously a ES6 Map, now an ES5-compliant object) var validatedSelectorsCache = {}; // Only pseudo-classes that accept selector lists should recurse @@ -746,6 +827,28 @@ CSSOM.parse = function parse(token, opts, errorHandler) { return definedNamespacePrefixes.hasOwnProperty(namespacePrefix); } + /** + * Processes a CSS selector text + * + * @param {string} selectorText - The CSS selector text to process + * @returns {string} The processed selector text with normalized whitespace + */ + function processSelectorText(selectorText) { + // TODO: Remove invalid selectors that appears inside pseudo classes + // TODO: The same processing here needs to be reused in CSSStyleRule.selectorText setter + // TODO: Move these validation logic to a shared function to be reused in CSSStyleRule.selectorText setter + + /** + * Normalizes whitespace and preserving quoted strings. + * Replaces all newline characters (CRLF, CR, or LF) with spaces while keeping quoted + * strings (single or double quotes) intact, including any escaped characters within them. + */ + return selectorText.replace(/(['"])(?:\\.|[^\\])*?\1|(\r\n|\r|\n)/g, function(match, _, newline) { + if (newline) return " "; + return match; + }); + } + /** * Checks if a given CSS selector text is valid by splitting it by commas * and validating each individual selector using the `validateSelector` function. @@ -754,6 +857,9 @@ CSSOM.parse = function parse(token, opts, errorHandler) { * @returns {boolean} Returns true if all selectors are valid, otherwise false. */ function isValidSelectorText(selectorText) { + // TODO: The same validations here needs to be reused in CSSStyleRule.selectorText setter + // TODO: Move these validation logic to a shared function to be reused in CSSStyleRule.selectorText setter + // Check for newlines inside single or double quotes using regex // This matches any quoted string (single or double) containing a newline var quotedNewlineRegExp = /(['"])(?:\\.|[^\\])*?\1/g; @@ -880,7 +986,8 @@ CSSOM.parse = function parse(token, opts, errorHandler) { i += 2; index = token.indexOf("*/", i); if (index === -1) { - parseError("Missing */"); + i = token.length - 1; + buffer = ""; } else { i = index + 1; } @@ -953,6 +1060,15 @@ CSSOM.parse = function parse(token, opts, errorHandler) { }); buffer = ""; break; + } else if (token.indexOf("@page", i) === i) { + validateAtRule("@page", function(){ + state = "pageBlock" + pageRule = new CSSOM.CSSPageRule(); + pageRule.__starts = i; + i += "page".length; + }); + buffer = ""; + break; } else if (token.indexOf("@supports", i) === i) { validateAtRule("@supports", function(){ state = "conditionBlock"; @@ -1050,10 +1166,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } currentScope = parentRule = styleRule; - styleRule.selectorText = buffer.trim().replace(/(['"])(?:\\.|[^\\])*?\1|(\r\n|\r|\n)/g, function(match, _, newline) { - if (newline) return " "; - return match; - }); + styleRule.selectorText = processSelectorText(buffer.trim()); styleRule.style.__starts = i; styleRule.__parentStyleSheet = styleSheet; buffer = ""; @@ -1071,7 +1184,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { buffer = ""; state = "before-selector"; } else if (state === "containerBlock") { - containerRule.containerText = buffer.trim(); + containerRule.__conditionText = buffer.trim(); if (parentRule) { containerRule.__parentRule = parentRule; @@ -1088,7 +1201,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { counterStyleRule.__parentStyleSheet = styleSheet; buffer = ""; } else if (state === "conditionBlock") { - supportsRule.conditionText = buffer.trim(); + supportsRule.__conditionText = buffer.trim(); if (parentRule) { supportsRule.__parentRule = parentRule; @@ -1123,7 +1236,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } else if (state === "layerBlock") { layerBlockRule.name = buffer.trim(); - var isValidName = layerBlockRule.name.length === 0 || layerBlockRule.name.match(layerRuleNameRegExp) !== null; + var isValidName = layerBlockRule.name.length === 0 || layerBlockRule.name.match(cssCustomIdentifierRegExp) !== null; if (isValidName) { if (parentRule) { @@ -1136,6 +1249,19 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } buffer = ""; state = "before-selector"; + } else if (state === "pageBlock") { + pageRule.selectorText = buffer.trim(); + + if (parentRule) { + pageRule.__parentRule = parentRule; + ancestorRules.push(parentRule); + } + + currentScope = parentRule = pageRule; + pageRule.__parentStyleSheet = styleSheet; + styleRule = pageRule; + buffer = ""; + state = "before-name"; } else if (state === "hostRule-begin") { if (parentRule) { ancestorRules.push(parentRule); @@ -1209,10 +1335,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } styleRule = new CSSOM.CSSStyleRule(); - var processedSelectorText = buffer.trim().replace(/(['"])(?:\\.|[^\\])*?\1|(\r\n|\r|\n)/g, function(match, _, newline) { - if (newline) return " "; - return match; - }); + var processedSelectorText = processSelectorText(buffer.trim()); // In a nested selector, ensure each selector contains '&' at the beginning, except for selectors that already have '&' somewhere if (parentRule.constructor.name !== "CSSStyleRule" && parentRule.parentRule === null) { styleRule.selectorText = processedSelectorText; @@ -1336,7 +1459,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { testNamespaceRule.cssText = buffer + character; namespaceRule = testNamespaceRule; - namespaceRule.__parentStyleSheet = namespaceRule.styleSheet.__parentStyleSheet = styleSheet; + namespaceRule.__parentStyleSheet = styleSheet; styleSheet.cssRules.push(namespaceRule); // Track the namespace prefix for validation @@ -1355,7 +1478,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { return name.trim(); }); var isInvalid = parentRule !== undefined || nameListStr.some(function (name) { - return name.trim().match(layerRuleNameRegExp) === null; + return name.trim().match(cssCustomIdentifierRegExp) === null; }); if (!isInvalid) { @@ -1582,6 +1705,10 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } } + if (buffer.trim() !== "") { + parseError("Unexpected end of input"); + } + return styleSheet; }; @@ -1611,6 +1738,7 @@ CSSOM.CSSDocumentRule = require('./CSSDocumentRule').CSSDocumentRule; CSSOM.CSSScopeRule = require('./CSSScopeRule').CSSScopeRule; CSSOM.CSSLayerBlockRule = require("./CSSLayerBlockRule").CSSLayerBlockRule; CSSOM.CSSLayerStatementRule = require("./CSSLayerStatementRule").CSSLayerStatementRule; +CSSOM.CSSPageRule = require("./CSSPageRule").CSSPageRule; // Use cssstyle if available try { CSSOM.CSSStyleDeclaration = require("cssstyle").CSSStyleDeclaration; diff --git a/spec/parse.spec.js b/spec/parse.spec.js index 5951783..9548153 100644 --- a/spec/parse.spec.js +++ b/spec/parse.spec.js @@ -571,6 +571,7 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "handheld, only screen and (max-device-width: 480px)", media: { 0: "handheld", 1: "only screen and (max-device-width: 480px)", @@ -609,6 +610,7 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "screen, screen, screen", media: { 0: "screen", 1: "screen", @@ -633,6 +635,7 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "print", media: { 0: "print", length: 1 @@ -681,6 +684,7 @@ var TESTS = [ __ends: 3 }, { + conditionText: "all", media: { 0: "all", length: 1 @@ -719,6 +723,7 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "(hover:hover)", media: { 0: "(hover:hover)", length: 1 @@ -761,6 +766,7 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "screen", media: { 0: "screen", length: 1 @@ -795,15 +801,14 @@ var TESTS = [ var result = { cssRules: [ { - parentRule: null, + conditionText: "(min-width: 768px)", media: { 0: "(min-width: 768px)", length: 1 }, - // This is currently incorrect. - // conditionText: "(min-width: 768px)", cssRules: [ { + conditionText: "(min-resolution: 0.001dpcm)", media: { 0: "(min-resolution: 0.001dpcm)", length: 1 @@ -824,7 +829,8 @@ var TESTS = [ } ], } - ] + ], + parentRule: null } ], parentStyleSheet: null @@ -909,6 +915,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } }, @@ -944,6 +953,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } }, @@ -980,6 +992,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } }, @@ -1016,6 +1031,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } }, @@ -1051,6 +1069,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] }, supportsText: "display: grid" @@ -1088,6 +1109,9 @@ var TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } } @@ -1187,6 +1211,11 @@ var TESTS = [ { cssRules: { 0: { + conditionText: "screen", + media: { + 0: "screen", + length: 1 + }, cssRules: { 0: { cssRules: [], @@ -1200,10 +1229,6 @@ var TESTS = [ background: "red", }, }, - }, - media: { - 0: "screen", - length: 1 } }, }, @@ -1223,6 +1248,11 @@ var TESTS = [ var result = { cssRules: [ { + conditionText: "screen", + media: { + 0: "screen", + length: 1 + }, cssRules: { 0: { cssRules: { @@ -1241,11 +1271,7 @@ var TESTS = [ }, }, }, - parentRule: null, - media: { - 0: "screen", - length: 1 - } + parentRule: null }, ], parentStyleSheet: null, @@ -1500,7 +1526,9 @@ var TESTS = [ var result = { cssRules: [ { - containerText: "sidebar (min-width: 400px)", + conditionText: "sidebar (min-width: 400px)", + containerName: "sidebar", + containerQuery: "(min-width: 400px)", cssRules: [ { cssRules: [], @@ -1828,10 +1856,6 @@ var CSS_NAMESPACE_TESTS = [ prefix: "custom", namespaceURI: "http://example.com", parentStyleSheet: null, - styleSheet: { - cssRules: [], - parentStyleSheet: null - }, parentRule: null, }, { @@ -1851,7 +1875,6 @@ var CSS_NAMESPACE_TESTS = [ parentStyleSheet: null }; result.cssRules[0].parentStyleSheet = result; - result.cssRules[0].styleSheet.parentStyleSheet = result; result.cssRules[1].parentStyleSheet = result; result.cssRules[1].style.parentRule = result.cssRules[1]; return result; @@ -1930,6 +1953,7 @@ var CSS_NESTING_TESTS = [ }, cssRules: [ { + conditionText: "all", media: { 0: "all", length: 1 @@ -1965,6 +1989,7 @@ var CSS_NESTING_TESTS = [ var result = { cssRules: [ { + conditionText: "all", media: { 0: "all", length: 1 @@ -1989,6 +2014,7 @@ var CSS_NESTING_TESTS = [ }, cssRules: [ { + conditionText: "print", media: { 0: "print", length: 1 @@ -2509,6 +2535,11 @@ var CSS_NESTING_TESTS = [ var result = { cssRules: [ { + conditionText: "only screen", + media: { + 0: "only screen", + length: 1 + }, cssRules: [ { cssRules: [ @@ -2539,10 +2570,6 @@ var CSS_NESTING_TESTS = [ ] } ], - media: { - 0: "only screen", - length: 1 - }, parentRule: null, }, ], @@ -2721,6 +2748,7 @@ var VALIDATION_TESTS = [ var result = { cssRules: [ { + conditionText: "all", media: { 0: "all", length: 1 @@ -2852,6 +2880,9 @@ var VALIDATION_TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } } @@ -2879,6 +2910,9 @@ var VALIDATION_TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } } @@ -2906,6 +2940,9 @@ var VALIDATION_TESTS = [ }, parentRule: null, styleSheet: { + media: { + length: 0 + }, cssRules: [] } } @@ -3212,6 +3249,14 @@ function itParse(input, result) { removeUnderscored(parsed); removeUnderscored(result); + + // Add default media to root result + if (!result.media) { + result.media = { + length: 0 + } + } + expect(parsed).toEqualOwnProperties(result); } diff --git a/src/files.js b/src/files.js index 77660c7..f499291 100644 --- a/src/files.js +++ b/src/files.js @@ -25,6 +25,7 @@ exports.files = [ "CSSScopeRule", "CSSLayerBlockRule", "CSSLayerStatementRule", + "CSSPageRule", "MatcherList", "CSSDocumentRule", "CSSValue",