diff --git a/.changeset/brown-teachers-provide.md b/.changeset/brown-teachers-provide.md new file mode 100644 index 0000000..6886660 --- /dev/null +++ b/.changeset/brown-teachers-provide.md @@ -0,0 +1,5 @@ +--- +"@acemir/cssom": patch +--- + +feat: support legacy CSSStyleSheet methods and improve nested selectors validation diff --git a/lib/CSSStyleSheet.js b/lib/CSSStyleSheet.js index 403ccc9..55abf5f 100644 --- a/lib/CSSStyleSheet.js +++ b/lib/CSSStyleSheet.js @@ -156,6 +156,13 @@ CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) { return index; }; +CSSOM.CSSStyleSheet.prototype.addRule = function(selector, styleBlock, index) { + if (index === void 0) { + index = this.cssRules.length; + } + this.insertRule(selector + "{" + styleBlock + "}", index); + return -1; +}; /** * Used to delete a rule from the style sheet. @@ -192,6 +199,9 @@ CSSOM.CSSStyleSheet.prototype.deleteRule = function(index) { this.cssRules.splice(index, 1); }; +CSSOM.CSSStyleSheet.prototype.removeRule = function(index) { + this.deleteRule(index); +}; /** * NON-STANDARD diff --git a/lib/parse.js b/lib/parse.js index d226ee1..bacb041 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -367,6 +367,22 @@ CSSOM.parse = function parse(token, opts, errorHandler) { return false; }; + /** + * Checks for invalid nesting selector (&) usage. + * The & selector cannot be directly followed by a type selector without a delimiter. + * Valid: &.class, &#id, &[attr], &:hover, &::before, & div, &>div + * Invalid: &div, &span + * @param {string} selector - The CSS selector to check + * @returns {boolean} True if the selector contains invalid & usage + */ + function hasInvalidNestingSelector(selector) { + // Check for & followed directly by a letter (type selector) without any delimiter + // This regex matches & followed by a letter (start of type selector) that's not preceded by an escape + // We need to exclude valid cases like &.class, &#id, &[attr], &:pseudo, &::pseudo, & (with space), &> + var invalidNestingPattern = /&(?![.\#\[:>\+~\s])[a-zA-Z]/; + return invalidNestingPattern.test(selector); + }; + function validateAtRule(atRuleKey, validCallback, cannotBeNested) { var isValid = false; var sourceRuleRegExp = atRuleKey === "@import" ? forwardImportRuleValidationRegExp : forwardRuleValidationRegExp; @@ -511,6 +527,11 @@ CSSOM.parse = function parse(token, opts, errorHandler) { return false; } + // Check for invalid nesting selector (&) usage + if (hasInvalidNestingSelector(selector)) { + 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 @@ -549,48 +570,56 @@ CSSOM.parse = function parse(token, opts, errorHandler) { * @returns {string[]} An array of selector parts, split by top-level commas, with whitespace trimmed. */ function parseAndSplitNestedSelectors(selector) { - var depth = 0; - var buffer = ""; - var parts = []; - var inSingleQuote = false; - var inDoubleQuote = false; + var depth = 0; // Track parenthesis nesting depth + var buffer = ""; // Accumulate characters for current selector part + var parts = []; // Array of split selector parts + var inSingleQuote = false; // Track if we're inside single quotes + var inDoubleQuote = false; // Track if we're inside double quotes var i, char; for (i = 0; i < selector.length; i++) { char = selector.charAt(i); + // Handle single quote strings if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; buffer += char; - } else if (char === '"' && !inSingleQuote) { + } + // Handle double quote strings + else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; buffer += char; - } else if (!inSingleQuote && !inDoubleQuote) { + } + // Process characters outside of quoted strings + else if (!inSingleQuote && !inDoubleQuote) { if (char === '(') { + // Entering a nested level (e.g., :is(...)) depth++; buffer += char; } else if (char === ')') { + // Exiting a nested level depth--; buffer += char; - if (depth === 0) { - parts.push(buffer.replace(/^\s+|\s+$/g, "")); - buffer = ""; - } } else if (char === ',' && depth === 0) { - if (buffer.replace(/^\s+|\s+$/g, "")) { - parts.push(buffer.replace(/^\s+|\s+$/g, "")); + // Found a top-level comma separator - split here + if (buffer.trim()) { + parts.push(buffer.trim()); } buffer = ""; } else { + // Regular character - add to buffer buffer += char; } - } else { + } + // Characters inside quoted strings - add to buffer + else { buffer += char; } } - if (buffer.replace(/^\s+|\s+$/g, "")) { - parts.push(buffer.replace(/^\s+|\s+$/g, "")); + // Add any remaining content in buffer as the last part + if (buffer.trim()) { + parts.push(buffer.trim()); } return parts; @@ -607,8 +636,8 @@ CSSOM.parse = function parse(token, opts, errorHandler) { * @returns {boolean} Returns `true` if the selector is valid, otherwise `false`. */ - // Cache to store validated selectors - var validatedSelectorsCache = new Map(); + // Cache to store validated selectors (ES5-compliant object) + var validatedSelectorsCache = {}; // Only pseudo-classes that accept selector lists should recurse var selectorListPseudoClasses = { @@ -619,8 +648,8 @@ CSSOM.parse = function parse(token, opts, errorHandler) { }; function validateSelector(selector) { - if (validatedSelectorsCache.has(selector)) { - return validatedSelectorsCache.get(selector); + if (validatedSelectorsCache.hasOwnProperty(selector)) { + return validatedSelectorsCache[selector]; } // Use a non-global regex to find all pseudo-classes with arguments @@ -637,15 +666,15 @@ CSSOM.parse = function parse(token, opts, errorHandler) { var nestedSelectors = parseAndSplitNestedSelectors(pseudoClassMatches[j][2]); for (var i = 0; i < nestedSelectors.length; i++) { var nestedSelector = nestedSelectors[i]; - if (!validatedSelectorsCache.has(nestedSelector)) { + if (!validatedSelectorsCache.hasOwnProperty(nestedSelector)) { var nestedSelectorValidation = validateSelector(nestedSelector); - validatedSelectorsCache.set(nestedSelector, nestedSelectorValidation); + validatedSelectorsCache[nestedSelector] = nestedSelectorValidation; if (!nestedSelectorValidation) { - validatedSelectorsCache.set(selector, false); + validatedSelectorsCache[selector] = false; return false; } - } else if (!validatedSelectorsCache.get(nestedSelector)) { - validatedSelectorsCache.set(selector, false); + } else if (!validatedSelectorsCache[nestedSelector]) { + validatedSelectorsCache[selector] = false; return false; } } @@ -653,7 +682,7 @@ CSSOM.parse = function parse(token, opts, errorHandler) { } var basicSelectorValidation = basicSelectorValidator(selector); - validatedSelectorsCache.set(selector, basicSelectorValidation); + validatedSelectorsCache[selector] = basicSelectorValidation; return basicSelectorValidation; } diff --git a/spec/CSSStyleSheet.spec.js b/spec/CSSStyleSheet.spec.js index 9da5024..fe9a722 100644 --- a/spec/CSSStyleSheet.spec.js +++ b/spec/CSSStyleSheet.spec.js @@ -11,6 +11,12 @@ describe('CSSOM', function() { s.insertRule("a *:first-child, a img {border: none}", 1); expect(s.cssRules.length).toBe(2); + s.addRule("b", "color: red"); + expect(s.cssRules.length).toBe(3); + + s.removeRule(2); + expect(s.cssRules.length).toBe(2); + s.deleteRule(1); expect(s.cssRules.length).toBe(1); diff --git a/spec/parse.spec.js b/spec/parse.spec.js index 3c81d6e..5951783 100644 --- a/spec/parse.spec.js +++ b/spec/parse.spec.js @@ -2468,6 +2468,40 @@ var CSS_NESTING_TESTS = [ return result; })() }, + { + // Nested Selector (keep & when it comes right after pseudo-class function ) + input: ".foo { :is(div)& { color: green; }}", + result: (function() { + var result = { + cssRules: [ + { + selectorText: ".foo", + style: { + length: 0 + }, + cssRules: [ + { + cssRules: [], + selectorText: ":is(div)&", + style: { + 0: "color", + color: "green", + length: 1 + }, + } + ], + parentRule: null, + }, + ], + parentStyleSheet: null + }; + result.cssRules[0].parentStyleSheet = result.cssRules[0].cssRules[0].parentStyleSheet = result; + result.cssRules[0].cssRules[0].parentRule = result.cssRules[0]; + result.cssRules[0].style.parentRule = result.cssRules[0]; + result.cssRules[0].cssRules[0].style.parentRule = result.cssRules[0].cssRules[0]; + return result; + })() + }, { // Deep Nested At-Rule Selector + Deep Nested Selector + Nested Declaration input: "@media only screen { @starting-style { html { &:not([lang]) { color: gray; } background: plum } } }", @@ -3139,6 +3173,29 @@ var VALIDATION_TESTS = [ parentStyleSheet: null } }, + { + // Invalid Nested Selector + input: "a { &div { color: black; } }", + result: (function() { + var result = { + cssRules: [ + { + selectorText: "a", + style: { + length: 0 + }, + cssRules: [ + ], + parentRule: null, + }, + ], + parentStyleSheet: null + }; + result.cssRules[0].parentStyleSheet = result; + result.cssRules[0].style.parentRule = result.cssRules[0]; + return result; + })() + }, ] function itParse(input, result) {