Skip to content

Commit 374d9a5

Browse files
authored
feat: improve parse and options (#43)
* feat: add opts to CSSOM.parse * feat: improve insertRule and deleteRule edge cases * feat: improve insertRule and deleteRule edge cases * fix: `@font-face` cannot be nested * fix: throw correct error on insertRule invalid index * fix `@import` canonical text and layer validation * fix: adjust selectorText values formatting * chore: add changeset Enhance CSSOM parsing options and error handling.
1 parent d6d2771 commit 374d9a5

14 files changed

+603
-74
lines changed

.changeset/sharp-laws-smoke.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@acemir/cssom": patch
3+
---
4+
5+
feat: improve parse options
6+
- add opts to CSSOM.parse
7+
- improve insertRule and deleteRule edge cases
8+
- adjust `@font-face` cannot be nested
9+
- throw correct error on insertRule invalid index
10+
- adjust `@import` canonical text and layer validation
11+
- adjust selectorText values formatting

docs/parse.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ function outputUpdated() {
157157
var value = style.value;
158158
if (value !== style.prevValue) {
159159
style.prevValue = value;
160-
var css = CSSOM.parse(value);
160+
var css = CSSOM.parse(value, undefined, true);
161161
window._last_parsed = css;
162162
uncircularOwnProperties(css);
163163
output.innerHTML = '';

lib/CSSFontFaceRule.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Object.defineProperty(CSSOM.CSSFontFaceRule.prototype, "style", {
4545
// http://www.opensource.apple.com/source/WebCore/WebCore-955.66.1/css/WebKitCSSFontFaceRule.cpp
4646
Object.defineProperty(CSSOM.CSSFontFaceRule.prototype, "cssText", {
4747
get: function() {
48-
return "@font-face { " + this.style.cssText + " }";
48+
return "@font-face {" + (this.style.cssText ? " " + this.style.cssText : "") + " }";
4949
}
5050
});
5151

lib/CSSGroupingRule.js

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,41 @@ CSSOM.CSSGroupingRule.prototype.constructor = CSSOM.CSSGroupingRule;
3737
* @return {number} The index within the grouping rule's collection of the newly inserted rule.
3838
*/
3939
CSSOM.CSSGroupingRule.prototype.insertRule = function insertRule(rule, index) {
40-
if (index < 0 || index > this.cssRules.length) {
41-
errorUtils.throwIndexSizeError(this);
40+
if (rule === undefined && index === undefined) {
41+
errorUtils.throwMissingArguments(this, 'insertRule', this.constructor.name);
4242
}
43-
var cssRule = CSSOM.parse(rule).cssRules[0];
43+
if (index === void 0) {
44+
index = 0;
45+
}
46+
index = Number(index);
47+
if (index < 0) {
48+
index = 4294967296 + index;
49+
}
50+
if (index > this.cssRules.length) {
51+
errorUtils.throwIndexError(this, 'insertRule', this.constructor.name, index, this.cssRules.length);
52+
}
53+
54+
var ruleToParse = String(rule);
55+
var parsedSheet = CSSOM.parse(ruleToParse);
56+
if (parsedSheet.cssRules.length !== 1) {
57+
errorUtils.throwParseError(this, 'insertRule', this.constructor.name, ruleToParse, 'SyntaxError');
58+
}
59+
var cssRule = parsedSheet.cssRules[0];
60+
61+
// Check for rules that cannot be inserted inside a CSSGroupingRule
62+
if (cssRule.constructor.name === 'CSSImportRule' || cssRule.constructor.name === 'CSSNamespaceRule') {
63+
var ruleKeyword = cssRule.constructor.name === 'CSSImportRule' ? '@import' : '@namespace';
64+
errorUtils.throwError(this, 'DOMException',
65+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': " +
66+
"'" + ruleKeyword + "' rules cannot be inserted inside a group rule.",
67+
'HierarchyRequestError');
68+
}
69+
70+
// Check for CSSLayerStatementRule (@layer statement rules)
71+
if (cssRule.constructor.name === 'CSSLayerStatementRule') {
72+
errorUtils.throwParseError(this, 'insertRule', this.constructor.name, ruleToParse, 'SyntaxError');
73+
}
74+
4475
cssRule.__parentRule = this;
4576
this.cssRules.splice(index, 0, cssRule);
4677
return index;
@@ -59,8 +90,15 @@ CSSOM.CSSGroupingRule.prototype.constructor = CSSOM.CSSGroupingRule;
5990
* @see https://www.w3.org/TR/cssom-1/#dom-cssgroupingrule-deleterule
6091
*/
6192
CSSOM.CSSGroupingRule.prototype.deleteRule = function deleteRule(index) {
62-
if (index < 0 || index >= this.cssRules.length) {
63-
errorUtils.throwIndexSizeError(this);
93+
if (index === undefined) {
94+
errorUtils.throwMissingArguments(this, 'deleteRule', this.constructor.name);
95+
}
96+
index = Number(index);
97+
if (index < 0) {
98+
index = 4294967296 + index;
99+
}
100+
if (index >= this.cssRules.length) {
101+
errorUtils.throwIndexError(this, 'deleteRule', this.constructor.name, index, this.cssRules.length);
64102
}
65103
this.cssRules.splice(index, 1)[0].__parentRule = null;
66104
};

lib/CSSImportRule.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ CSSOM.CSSImportRule.prototype.type = 3;
2828
Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", {
2929
get: function() {
3030
var mediaText = this.media.mediaText;
31-
return "@import url(" + this.href + ")" + (this.layerName !== null ? " layer" + (this.layerName && "(" + this.layerName + ")") : "" ) + (this.supportsText ? " supports(" + this.supportsText + ")" : "" ) + (mediaText ? " " + mediaText : "") + ";";
31+
return "@import url(\"" + this.href + "\")" + (this.layerName !== null ? " layer" + (this.layerName && "(" + this.layerName + ")") : "" ) + (this.supportsText ? " supports(" + this.supportsText + ")" : "" ) + (mediaText ? " " + mediaText : "") + ";";
3232
},
3333
set: function(cssText) {
3434
var i = 0;
@@ -46,7 +46,7 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", {
4646
var index;
4747

4848
var layerRegExp = /layer\(([^)]*)\)/;
49-
var layerRuleNameRegExp = /^(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)$/;
49+
var layerRuleNameRegExp = /^(-?[_a-zA-Z]+(\.[_a-zA-Z]+)*[_a-zA-Z0-9-]*)$/;
5050
var supportsRegExp = /supports\(([^)]+)\)/;
5151
var doubleOrMoreSpacesRegExp = /\s{2,}/g;
5252

@@ -129,12 +129,12 @@ Object.defineProperty(CSSOM.CSSImportRule.prototype, "cssText", {
129129

130130
if (layerMatch) {
131131
var layerName = layerMatch[1].trim();
132-
bufferTrimmed = bufferTrimmed.replace(layerRegExp, '')
133-
.replace(doubleOrMoreSpacesRegExp, ' ') // Replace double or more spaces with single space
134-
.trim();
135132

136133
if (layerName.match(layerRuleNameRegExp) !== null) {
137134
this.layerName = layerMatch[1].trim();
135+
bufferTrimmed = bufferTrimmed.replace(layerRegExp, '')
136+
.replace(doubleOrMoreSpacesRegExp, ' ') // Replace double or more spaces with single space
137+
.trim();
138138
} else {
139139
// REVIEW: In the browser, an empty layer() is not processed as a unamed layer
140140
// and treats the rest of the string as mediaText, ignoring the parse of supports()

lib/CSSKeyframeRule.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Object.defineProperty(CSSOM.CSSKeyframeRule.prototype, "style", {
4646
// http://www.opensource.apple.com/source/WebCore/WebCore-955.66.1/css/WebKitCSSKeyframeRule.cpp
4747
Object.defineProperty(CSSOM.CSSKeyframeRule.prototype, "cssText", {
4848
get: function() {
49-
return this.keyText + " { " + this.style.cssText + " }";
49+
return this.keyText + " {" + (this.style.cssText ? " " + this.style.cssText : "") + " }";
5050
}
5151
});
5252

lib/CSSStyleRule.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Object.defineProperty(CSSOM.CSSStyleRule.prototype, "cssText", {
6666
valuesArr.push(this.cssRules.map(function(rule){ return rule.cssText }).join("\n "));
6767
values = valuesArr.join("\n ") + "\n}"
6868
} else {
69-
values = " { " + this.style.cssText + " }";
69+
values = " {" + (this.style.cssText ? " " + this.style.cssText : "") + " }";
7070
}
7171
text = this.selectorText + values;
7272
} else {

lib/CSSStyleSheet.js

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,112 @@ CSSOM.CSSStyleSheet.prototype.constructor = CSSOM.CSSStyleSheet;
3939
*/
4040
CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) {
4141
if (rule === undefined && index === undefined) {
42-
errorUtils.throwMissingArguments(this, 'insertRule', 'CSSStyleSheet');
42+
errorUtils.throwMissingArguments(this, 'insertRule', this.constructor.name);
4343
}
4444
if (index === void 0) {
4545
index = 0;
4646
}
47-
if (index < 0 || index > this.cssRules.length) {
48-
errorUtils.throwIndexSizeError(this);
47+
index = Number(index);
48+
if (index < 0) {
49+
index = 4294967296 + index;
50+
}
51+
if (index > this.cssRules.length) {
52+
errorUtils.throwIndexError(this, 'insertRule', this.constructor.name, index, this.cssRules.length);
4953
}
54+
5055
var ruleToParse = String(rule);
5156
var parsedSheet = CSSOM.parse(ruleToParse);
5257
if (parsedSheet.cssRules.length !== 1) {
53-
var domExceptionName = "SyntaxError";
54-
if (ruleToParse.trimStart().startsWith('@namespace')) {
55-
domExceptionName = "InvalidStateError";
56-
}
57-
errorUtils.throwParseError(this, 'insertRule', 'CSSStyleSheet', ruleToParse, domExceptionName);
58+
errorUtils.throwParseError(this, 'insertRule', this.constructor.name, ruleToParse, 'SyntaxError');
5859
}
5960
var cssRule = parsedSheet.cssRules[0];
61+
62+
// Helper function to find the last index of a specific rule constructor
63+
function findLastIndexOfConstructor(rules, constructorName) {
64+
for (var i = rules.length - 1; i >= 0; i--) {
65+
if (rules[i].constructor.name === constructorName) {
66+
return i;
67+
}
68+
}
69+
return -1;
70+
}
71+
72+
// Helper function to find the first index of a rule that's NOT of specified constructors
73+
function findFirstNonConstructorIndex(rules, constructorNames) {
74+
for (var i = 0; i < rules.length; i++) {
75+
if (constructorNames.indexOf(rules[i].constructor.name) === -1) {
76+
return i;
77+
}
78+
}
79+
return rules.length;
80+
}
81+
82+
// Validate rule ordering based on CSS specification
83+
if (cssRule.constructor.name === 'CSSImportRule') {
84+
// @import rules cannot be inserted after @layer rules that already exist
85+
// They can only be inserted at the beginning or after other @import rules
86+
var firstLayerIndex = findFirstNonConstructorIndex(this.cssRules, ['CSSImportRule']);
87+
if (firstLayerIndex < this.cssRules.length && this.cssRules[firstLayerIndex].constructor.name === 'CSSLayerStatementRule' && index > firstLayerIndex) {
88+
errorUtils.throwError(this, 'DOMException',
89+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.",
90+
'HierarchyRequestError');
91+
}
92+
93+
// Also cannot insert after @namespace or other rules
94+
var firstNonImportIndex = findFirstNonConstructorIndex(this.cssRules, ['CSSImportRule']);
95+
if (index > firstNonImportIndex && firstNonImportIndex < this.cssRules.length &&
96+
this.cssRules[firstNonImportIndex].constructor.name !== 'CSSLayerStatementRule') {
97+
errorUtils.throwError(this, 'DOMException',
98+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.",
99+
'HierarchyRequestError');
100+
}
101+
} else if (cssRule.constructor.name === 'CSSNamespaceRule') {
102+
// @namespace rules can come after @layer and @import, but before any other rules
103+
// They cannot come before @import rules
104+
var firstImportIndex = -1;
105+
for (var i = 0; i < this.cssRules.length; i++) {
106+
if (this.cssRules[i].constructor.name === 'CSSImportRule') {
107+
firstImportIndex = i;
108+
break;
109+
}
110+
}
111+
var firstNonImportNamespaceIndex = findFirstNonConstructorIndex(this.cssRules, [
112+
'CSSLayerStatementRule',
113+
'CSSImportRule',
114+
'CSSNamespaceRule'
115+
]);
116+
117+
// Cannot insert before @import rules
118+
if (firstImportIndex !== -1 && index <= firstImportIndex) {
119+
errorUtils.throwError(this, 'DOMException',
120+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.",
121+
'HierarchyRequestError');
122+
}
123+
124+
// Cannot insert after other types of rules
125+
if (index > firstNonImportNamespaceIndex) {
126+
errorUtils.throwError(this, 'DOMException',
127+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.",
128+
'HierarchyRequestError');
129+
}
130+
} else if (cssRule.constructor.name === 'CSSLayerStatementRule') {
131+
// @layer statement rules can be inserted anywhere before @import and @namespace
132+
// No additional restrictions beyond what's already handled
133+
} else {
134+
// Any other rule cannot be inserted before @import and @namespace
135+
var firstNonSpecialRuleIndex = findFirstNonConstructorIndex(this.cssRules, [
136+
'CSSLayerStatementRule',
137+
'CSSImportRule',
138+
'CSSNamespaceRule'
139+
]);
140+
141+
if (index < firstNonSpecialRuleIndex) {
142+
errorUtils.throwError(this, 'DOMException',
143+
"Failed to execute 'insertRule' on '" + this.constructor.name + "': Failed to insert the rule.",
144+
'HierarchyRequestError');
145+
}
146+
}
147+
60148
cssRule.__parentStyleSheet = this;
61149
this.cssRules.splice(index, 0, cssRule);
62150
return index;
@@ -78,21 +166,21 @@ CSSOM.CSSStyleSheet.prototype.insertRule = function(rule, index) {
78166
*/
79167
CSSOM.CSSStyleSheet.prototype.deleteRule = function(index) {
80168
if (index === undefined) {
81-
errorUtils.throwMissingArguments(this, 'deleteRule', 'CSSStyleSheet');
169+
errorUtils.throwMissingArguments(this, 'deleteRule', this.constructor.name);
82170
}
83171
index = Number(index);
84172
if (index < 0) {
85173
index = 4294967296 + index;
86174
}
87175
if (index >= this.cssRules.length) {
88-
errorUtils.throwIndexError(this, 'deleteRule', 'CSSStyleSheet', index, this.cssRules.length);
176+
errorUtils.throwIndexError(this, 'deleteRule', this.constructor.name, index, this.cssRules.length);
89177
}
90178
if (this.cssRules[index] && this.cssRules[index].constructor.name == "CSSNamespaceRule") {
91179
var shouldContinue = this.cssRules.every(function (rule) {
92180
return ['CSSImportRule','CSSLayerStatementRule','CSSNamespaceRule'].indexOf(rule.constructor.name) !== -1
93181
});
94182
if (!shouldContinue) {
95-
errorUtils.throwError(this, 'DOMException', "Failed to execute 'deleteRule' on 'CSSStyleSheet': Deleting a CSSNamespaceRule is not allowed when there is rules other than @import, @layer statement, or @namespace.", "InvalidStateError");
183+
errorUtils.throwError(this, 'DOMException', "Failed to execute 'deleteRule' on '" + this.constructor.name + "': Failed to delete rule.", "InvalidStateError");
96184
}
97185
}
98186
this.cssRules.splice(index, 1);

lib/errorUtils.js

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,27 @@
22

33
/**
44
* Gets the appropriate error constructor from the global object context.
5-
* Tries to find the error constructor from parentStyleSheet._globalObject,
6-
* then from _globalObject, then falls back to the native constructor.
5+
* Tries to find the error constructor from parentStyleSheet.__globalObject,
6+
* then from __globalObject, then falls back to the native constructor.
77
*
88
* @param {Object} context - The CSSOM object (rule, stylesheet, etc.)
99
* @param {string} errorType - The error type ('TypeError', 'RangeError', 'DOMException', etc.)
1010
* @return {Function} The error constructor
1111
*/
1212
function getErrorConstructor(context, errorType) {
13-
// Try parentStyleSheet._globalObject first
14-
if (context.parentStyleSheet && context.parentStyleSheet._globalObject && context.parentStyleSheet._globalObject[errorType]) {
15-
return context.parentStyleSheet._globalObject[errorType];
13+
// Try parentStyleSheet.__globalObject first
14+
if (context.parentStyleSheet && context.parentStyleSheet.__globalObject && context.parentStyleSheet.__globalObject[errorType]) {
15+
return context.parentStyleSheet.__globalObject[errorType];
1616
}
1717

1818
// Try __parentStyleSheet (alternative naming)
19-
if (context.__parentStyleSheet && context.__parentStyleSheet._globalObject && context.__parentStyleSheet._globalObject[errorType]) {
20-
return context.__parentStyleSheet._globalObject[errorType];
19+
if (context.__parentStyleSheet && context.__parentStyleSheet.__globalObject && context.__parentStyleSheet.__globalObject[errorType]) {
20+
return context.__parentStyleSheet.__globalObject[errorType];
2121
}
2222

23-
// Try _globalObject on the context itself
24-
if (context._globalObject && context._globalObject[errorType]) {
25-
return context._globalObject[errorType];
23+
// Try __globalObject on the context itself
24+
if (context.__globalObject && context.__globalObject[errorType]) {
25+
return context.__globalObject[errorType];
2626
}
2727

2828
// Fall back to native constructor
@@ -63,16 +63,6 @@ function throwMissingArguments(context, methodName, objectName, required, provid
6363
throwError(context, 'TypeError', message);
6464
}
6565

66-
/**
67-
* Throws a RangeError for index out of bounds.
68-
*
69-
* @param {Object} context - The CSSOM object
70-
* @param {string} [message] - Optional custom message, defaults to 'INDEX_SIZE_ERR'
71-
*/
72-
function throwIndexSizeError(context, message) {
73-
throwError(context, 'RangeError', message || 'INDEX_SIZE_ERR');
74-
}
75-
7666
/**
7767
* Throws a DOMException for parse errors.
7868
*
@@ -108,7 +98,6 @@ var errorUtils = {
10898
getErrorConstructor: getErrorConstructor,
10999
throwError: throwError,
110100
throwMissingArguments: throwMissingArguments,
111-
throwIndexSizeError: throwIndexSizeError,
112101
throwParseError: throwParseError,
113102
throwIndexError: throwIndexError
114103
};

lib/parse.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ var CSSOM = {};
44

55

66
/**
7-
* @param {string} token
7+
* Parses a CSS string and returns a CSSOM.CSSStyleSheet object representing the parsed stylesheet.
8+
*
9+
* @param {string} token - The CSS string to parse.
10+
* @param {object} [opts] - Optional parsing options.
11+
* @param {object} [opts.globalObject] - An optional global object to attach to the stylesheet. Useful on jsdom webplatform tests.
12+
* @param {function|boolean} [errorHandler] - Optional error handler function or `true` to use `console.error`.
13+
* @returns {CSSOM.CSSStyleSheet} The parsed CSSStyleSheet object.
814
*/
9-
CSSOM.parse = function parse(token, errorHandler) {
10-
errorHandler = errorHandler === undefined ? (console && console.error) : errorHandler;
15+
CSSOM.parse = function parse(token, opts, errorHandler) {
16+
errorHandler = errorHandler === true ? (console && console.error) : errorHandler;
1117

1218
var i = 0;
1319

@@ -49,6 +55,10 @@ CSSOM.parse = function parse(token, errorHandler) {
4955

5056
var styleSheet = new CSSOM.CSSStyleSheet();
5157

58+
if (opts && opts.globalObject) {
59+
styleSheet.__globalObject = opts.globalObject;
60+
}
61+
5262
// @type CSSStyleSheet|CSSMediaRule|CSSContainerRule|CSSSupportsRule|CSSFontFaceRule|CSSKeyframesRule|CSSDocumentRule
5363
var currentScope = styleSheet;
5464

@@ -746,7 +756,7 @@ CSSOM.parse = function parse(token, errorHandler) {
746756
i += "font-face".length;
747757
fontFaceRule = new CSSOM.CSSFontFaceRule();
748758
fontFaceRule.__starts = i;
749-
}, parentRule && parentRule.constructor.name === "CSSStyleRule" );
759+
}, true);
750760
break;
751761
} else {
752762
atKeyframesRegExp.lastIndex = i;

0 commit comments

Comments
 (0)