Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-teachers-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@acemir/cssom": patch
---

feat: support legacy CSSStyleSheet methods and improve nested selectors validation
10 changes: 10 additions & 0 deletions lib/CSSStyleSheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
81 changes: 55 additions & 26 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -637,23 +666,23 @@ 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;
}
}
}
}

var basicSelectorValidation = basicSelectorValidator(selector);
validatedSelectorsCache.set(selector, basicSelectorValidation);
validatedSelectorsCache[selector] = basicSelectorValidation;

return basicSelectorValidation;
}
Expand Down
6 changes: 6 additions & 0 deletions spec/CSSStyleSheet.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
57 changes: 57 additions & 0 deletions spec/parse.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } }",
Expand Down Expand Up @@ -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) {
Expand Down