diff --git a/.eslintignore b/.eslintignore index 9f55fbbb..de78dd4f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,8 @@ node_modules +lib/CSSStyleDeclaration.js +lib/Function.js +lib/VoidFunction.js lib/implementedProperties.js lib/properties.js +lib/utils.js jest.config.js diff --git a/.gitignore b/.gitignore index 558dbbb4..4cd4b401 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules npm-debug.log +lib/CSSStyleDeclaration.js +lib/Function.js +lib/VoidFunction.js lib/implementedProperties.js lib/properties.js +lib/utils.js coverage +src/CSSStyleDeclaration-properties.webidl diff --git a/.npmignore b/.npmignore index 4c9a487d..bad2b5e1 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,7 @@ /* +!index.js +!webidl2js-wrapper.js !lib/ +lib/Function.js +lib/VoidFunction.js !LICENSE diff --git a/README.md b/README.md index fc4085a7..1050f217 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,47 @@ A Node JS implementation of the CSS Object Model [CSSStyleDeclaration interface] [![NpmVersion](https://img.shields.io/npm/v/cssstyle.svg)](https://www.npmjs.com/package/cssstyle) [![Build Status](https://travis-ci.org/jsdom/cssstyle.svg?branch=main)](https://travis-ci.org/jsdom/cssstyle) [![codecov](https://codecov.io/gh/jsdom/cssstyle/branch/main/graph/badge.svg)](https://codecov.io/gh/jsdom/cssstyle) ---- - -#### Background +## Background This package is an extension of the CSSStyleDeclaration class in Nikita Vasilyev's [CSSOM](https://github.com/NV/CSSOM) with added support for CSS 2 & 3 properties. The primary use case is for testing browser code in a Node environment. It was originally created by Chad Walker, it is now maintained by the jsdom community. Bug reports and pull requests are welcome. + +## APIs + +This package exposes two flavors of the `CSSStyleDeclaration` interface depending on the imported module. + +### `cssstyle` module + +This module default-exports the `CSSStyleDeclaration` interface constructor, with the change that it can be constructed with an optional `onChangeCallback` parameter. Whenever any CSS property is modified through an instance of this class, the callback (if provided) will be called with a string that represents all CSS properties of this element, serialized. This allows the embedding environment to properly reflect the style changes to an element's `style` attribute. + +Here is a crude example of using the `onChangeCallback` to implement the `style` property of `HTMLElement`: +```js +const CSSStyleDeclaration = require('cssstyle'); + +class HTMLElement extends Element { + constructor() { + this._style = new CSSStyleDeclaration(newCSSText => { + this.setAttributeNS(null, "style", newCSSText); + }); + } + + get style() { + return this._style; + } + + set style(text) { + this._style.cssText = text; + } +} +``` + +### `cssstyle/webidl2js-wrapper` module + +This module exports the `CSSStyleDeclaration` [interface wrapper API](https://github.com/jsdom/webidl2js#for-interfaces) generated by [webidl2js](https://github.com/jsdom/webidl2js). Unlike the default export, `CSSStyleDeclaration` constructors installed by the webidl2js wrapper do _not_ support construction, just like how they actually are in browsers. Creating new `CSSStyleDeclaration` objects can be done with the [`create`](https://github.com/jsdom/webidl2js#createglobalobject-constructorargs-privatedata) method of the wrapper. + +#### `privateData` + +The `privateData` parameter of `create` and `createImpl` provides a way to specify the `onChangeCallback` that is a constructor parameter in the default export. Only the `onChangeCallback` property is supported on `privateData` currently, with the same semantics as the constructor parameter documented above. diff --git a/index.js b/index.js new file mode 100644 index 00000000..9727f3df --- /dev/null +++ b/index.js @@ -0,0 +1,34 @@ +'use strict'; +const webidlWrapper = require('./webidl2js-wrapper.js'); + +const sharedGlobalObject = { Object, String, Number, Array, TypeError }; +webidlWrapper.install(sharedGlobalObject, ['Window']); + +const origCSSStyleDeclaration = sharedGlobalObject.CSSStyleDeclaration; + +/** + * @constructor + * @param {((cssText: string) => void) | null} [onChangeCallback] + * The callback that is invoked whenever a property changes. + */ +function CSSStyleDeclaration(onChangeCallback = null) { + if (new.target === undefined) { + throw new TypeError("Class constructor CSSStyleDeclaration cannot be invoked without 'new'"); + } + + if (onChangeCallback !== null && typeof onChangeCallback !== 'function') { + throw new TypeError('Failed to construct CSSStyleDeclaration: parameter 1 is not a function'); + } + + return webidlWrapper.create(sharedGlobalObject, undefined, { onChangeCallback }); +} + +sharedGlobalObject.CSSStyleDeclaration = CSSStyleDeclaration; +Object.defineProperty(CSSStyleDeclaration, 'prototype', { + value: origCSSStyleDeclaration.prototype, + writable: false, +}); +CSSStyleDeclaration.prototype.constructor = CSSStyleDeclaration; +Object.setPrototypeOf(CSSStyleDeclaration, Object.getPrototypeOf(origCSSStyleDeclaration)); + +module.exports = CSSStyleDeclaration; diff --git a/jest.config.js b/jest.config.js index 565eada6..e9ee0caf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,8 +3,12 @@ module.exports = { "collectCoverage": true, "collectCoverageFrom": [ "lib/**/*.js", + "!lib/CSSStyleDeclaration.js", + "!lib/Function.js", + "!lib/VoidFunction.js", "!lib/implementedProperties.js", "!lib/properties.js", + "!lib/utils.js", ], "coverageDirectory": "coverage", }; diff --git a/lib/CSSStyleDeclaration-impl.js b/lib/CSSStyleDeclaration-impl.js new file mode 100644 index 00000000..428e29ef --- /dev/null +++ b/lib/CSSStyleDeclaration-impl.js @@ -0,0 +1,236 @@ +/********************************************************************* + * This is a fork from the CSS Style Declaration part of + * https://github.com/NV/CSSOM + ********************************************************************/ +'use strict'; +const CSSOM = require('rrweb-cssom'); +const allProperties = require('./allProperties'); +const allExtraProperties = require('./allExtraProperties'); +const implementedProperties = require('./implementedProperties'); +const idlUtils = require('./utils.js'); + +class CSSStyleDeclarationImpl { + /** + * @constructor + * @see https://drafts.csswg.org/cssom/#cssstyledeclaration + * + * @param {object} globalObject + * @param {*[]} args + * @param {object} privateData + * @param {((cssText: string) => void) | null} [privateData.onChangeCallback] + */ + constructor(globalObject, args, { onChangeCallback }) { + this._globalObject = globalObject; + this._values = Object.create(null); + this._importants = Object.create(null); + this._list = []; + this._onChange = onChangeCallback; + this._setInProgress = false; + this.parentRule = null; + } + + /** + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-csstext + */ + get cssText() { + const { _list } = this; + const properties = []; + for (let i = 0; i < _list.length; i++) { + const name = _list[i]; + const value = this.getPropertyValue(name); + let priority = this.getPropertyPriority(name); + if (priority !== '') { + priority = ` !${priority}`; + } + properties.push(`${name}: ${value}${priority};`); + } + return properties.join(' '); + } + + set cssText(value) { + this._values = Object.create(null); + this._importants = Object.create(null); + this._list = []; + let dummyRule; + try { + dummyRule = CSSOM.parse('#bogus{' + value + '}').cssRules[0].style; + } catch (err) { + // malformed css, just return + return; + } + this._setInProgress = true; + const rule_length = dummyRule.length; + for (let i = 0; i < rule_length; ++i) { + const name = dummyRule[i]; + this.setProperty( + dummyRule[i], + dummyRule.getPropertyValue(name), + dummyRule.getPropertyPriority(name) + ); + } + this._setInProgress = false; + if (this._onChange) { + this._onChange(this.cssText); + } + } + + /** + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-length + */ + get length() { + return this._list.length; + } + + /** + * This deletes indices if the new length is less then the current + * length. If the new length is more, it does nothing, the new indices + * will be undefined until set. + **/ + set length(value) { + this._list.length = value; + } + + /** + * + * @param {string} name + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-getpropertyvalue + * @return {string} the value of the property if it has been explicitly set for this declaration block. + * Returns the empty string if the property has not been set. + */ + getPropertyValue(name) { + return this._values[name] || ''; + } + + /** + * + * @param {string} name + * @param {string} value + * @param {string} [priority=""] "important" or "" + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-setproperty + */ + setProperty(name, value, priority = '') { + if (value === '') { + this.removeProperty(name); + return; + } + + if (name.startsWith('--')) { + this._setProperty(name, value, priority); + return; + } + + const lowercaseName = name.toLowerCase(); + if (!allProperties.has(lowercaseName) && !allExtraProperties.has(lowercaseName)) { + return; + } + + if (implementedProperties.has(lowercaseName)) { + this[lowercaseName] = value; + } else { + this._setProperty(lowercaseName, value, priority); + } + this._importants[lowercaseName] = priority; + } + + /** + * @param {string} name + * @param {string | null} value + * @param {string} [priority=""] + */ + _setProperty(name, value, priority = '') { + // FIXME: A good chunk of the implemented properties call this method + // with `value = undefined`, expecting it to do nothing: + if (value === undefined) { + return; + } + if (value === null || value === '') { + this.removeProperty(name); + return; + } + + let originalText; + if (this._onChange) { + originalText = this.cssText; + } + + if (this._values[name]) { + // Property already exist. Overwrite it. + if (!this._list.includes(name)) { + this._list.push(name); + } + } else { + // New property. + this._list.push(name); + } + this._values[name] = value; + this._importants[name] = priority; + if (this._onChange && this.cssText !== originalText && !this._setInProgress) { + this._onChange(this.cssText); + } + } + + /** + * + * @param {string} name + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-removeproperty + * @return {string} the value of the property if it has been explicitly set for this declaration block. + * Returns the empty string if the property has not been set or the property name does not correspond to a known CSS property. + */ + removeProperty(name) { + if (!idlUtils.hasOwn(this._values, name)) { + return ''; + } + + const prevValue = this._values[name]; + delete this._values[name]; + delete this._importants[name]; + + const index = this._list.indexOf(name); + if (index < 0) { + return prevValue; + } + + // That's what WebKit and Opera do + this._list.splice(index, 1); + + // That's what Firefox does + //this._list[index] = '' + + if (this._onChange) { + this._onChange(this.cssText); + } + return prevValue; + } + + /** + * + * @param {String} name + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-getpropertypriority + */ + getPropertyPriority(name) { + return this._importants[name] || ''; + } + + /** + * @see https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-item + */ + item(index) { + const { _list } = this; + if (index < 0 || index >= _list.length) { + return ''; + } + return _list[index]; + } + + [idlUtils.supportsPropertyIndex](index) { + return index >= 0 && index < this._list.length; + } + + [idlUtils.supportedPropertyIndices]() { + return this._list.keys(); + } +} + +require('./properties')(CSSStyleDeclarationImpl.prototype); + +exports.implementation = CSSStyleDeclarationImpl; diff --git a/lib/CSSStyleDeclaration.js b/lib/CSSStyleDeclaration.js deleted file mode 100644 index 0a180f6f..00000000 --- a/lib/CSSStyleDeclaration.js +++ /dev/null @@ -1,271 +0,0 @@ -/********************************************************************* - * This is a fork from the CSS Style Declaration part of - * https://github.com/NV/CSSOM - ********************************************************************/ -'use strict'; -var CSSOM = require('rrweb-cssom'); -var allProperties = require('./allProperties'); -var allExtraProperties = require('./allExtraProperties'); -var implementedProperties = require('./implementedProperties'); -var { dashedToCamelCase } = require('./parsers'); -var getBasicPropertyDescriptor = require('./utils/getBasicPropertyDescriptor'); - -/** - * @constructor - * @see http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration - */ -var CSSStyleDeclaration = function CSSStyleDeclaration(onChangeCallback) { - this._values = {}; - this._importants = {}; - this._length = 0; - this._onChange = onChangeCallback; - this._setInProgress = false; -}; -CSSStyleDeclaration.prototype = { - constructor: CSSStyleDeclaration, - - /** - * - * @param {string} name - * @see http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration-getPropertyValue - * @return {string} the value of the property if it has been explicitly set for this declaration block. - * Returns the empty string if the property has not been set. - */ - getPropertyValue: function (name) { - if (!this._values.hasOwnProperty(name)) { - return ''; - } - return this._values[name].toString(); - }, - - /** - * - * @param {string} name - * @param {string} value - * @param {string} [priority=null] "important" or null - * @see http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration-setProperty - */ - setProperty: function (name, value, priority) { - if (value === undefined) { - return; - } - if (value === null || value === '') { - this.removeProperty(name); - return; - } - var isCustomProperty = name.indexOf('--') === 0; - if (isCustomProperty) { - this._setProperty(name, value, priority); - return; - } - var lowercaseName = name.toLowerCase(); - if (!allProperties.has(lowercaseName) && !allExtraProperties.has(lowercaseName)) { - return; - } - - this[lowercaseName] = value; - this._importants[lowercaseName] = priority; - }, - _setProperty: function (name, value, priority) { - if (value === undefined) { - return; - } - if (value === null || value === '') { - this.removeProperty(name); - return; - } - - var originalText; - if (this._onChange) { - originalText = this.cssText; - } - - if (this._values[name]) { - // Property already exist. Overwrite it. - var index = Array.prototype.indexOf.call(this, name); - if (index < 0) { - this[this._length] = name; - this._length++; - } - } else { - // New property. - this[this._length] = name; - this._length++; - } - this._values[name] = value; - this._importants[name] = priority; - if (this._onChange && this.cssText !== originalText && !this._setInProgress) { - this._onChange(this.cssText); - } - }, - - /** - * - * @param {string} name - * @see http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration-removeProperty - * @return {string} the value of the property if it has been explicitly set for this declaration block. - * Returns the empty string if the property has not been set or the property name does not correspond to a known CSS property. - */ - removeProperty: function (name) { - if (!this._values.hasOwnProperty(name)) { - return ''; - } - - var prevValue = this._values[name]; - delete this._values[name]; - delete this._importants[name]; - - var index = Array.prototype.indexOf.call(this, name); - if (index < 0) { - return prevValue; - } - - // That's what WebKit and Opera do - Array.prototype.splice.call(this, index, 1); - - // That's what Firefox does - //this[index] = "" - - if (this._onChange) { - this._onChange(this.cssText); - } - return prevValue; - }, - - /** - * - * @param {String} name - */ - getPropertyPriority: function (name) { - return this._importants[name] || ''; - }, - - getPropertyCSSValue: function () { - //FIXME - return; - }, - - /** - * element.style.overflow = "auto" - * element.style.getPropertyShorthand("overflow-x") - * -> "overflow" - */ - getPropertyShorthand: function () { - //FIXME - return; - }, - - isPropertyImplicit: function () { - //FIXME - return; - }, - - /** - * http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration-item - */ - item: function (index) { - index = parseInt(index, 10); - if (index < 0 || index >= this._length) { - return ''; - } - return this[index]; - }, -}; - -Object.defineProperties(CSSStyleDeclaration.prototype, { - cssText: { - get: function () { - var properties = []; - var i; - var name; - var value; - var priority; - for (i = 0; i < this._length; i++) { - name = this[i]; - value = this.getPropertyValue(name); - priority = this.getPropertyPriority(name); - if (priority !== '') { - priority = ' !' + priority; - } - properties.push([name, ': ', value, priority, ';'].join('')); - } - return properties.join(' '); - }, - set: function (value) { - var i; - this._values = {}; - Array.prototype.splice.call(this, 0, this._length); - this._importants = {}; - var dummyRule; - try { - dummyRule = CSSOM.parse('#bogus{' + value + '}').cssRules[0].style; - } catch (err) { - // malformed css, just return - return; - } - this._setInProgress = true; - var rule_length = dummyRule.length; - var name; - for (i = 0; i < rule_length; ++i) { - name = dummyRule[i]; - this.setProperty( - dummyRule[i], - dummyRule.getPropertyValue(name), - dummyRule.getPropertyPriority(name) - ); - } - this._setInProgress = false; - if (this._onChange) { - this._onChange(this.cssText); - } - }, - enumerable: true, - configurable: true, - }, - parentRule: { - get: function () { - return null; - }, - enumerable: true, - configurable: true, - }, - length: { - get: function () { - return this._length; - }, - /** - * This deletes indices if the new length is less then the current - * length. If the new length is more, it does nothing, the new indices - * will be undefined until set. - **/ - set: function (value) { - var i; - for (i = value; i < this._length; i++) { - delete this[i]; - } - this._length = value; - }, - enumerable: true, - configurable: true, - }, -}); - -require('./properties')(CSSStyleDeclaration.prototype); - -allProperties.forEach(function (property) { - if (!implementedProperties.has(property)) { - var declaration = getBasicPropertyDescriptor(property); - Object.defineProperty(CSSStyleDeclaration.prototype, property, declaration); - Object.defineProperty(CSSStyleDeclaration.prototype, dashedToCamelCase(property), declaration); - } -}); - -allExtraProperties.forEach(function (property) { - if (!implementedProperties.has(property)) { - var declaration = getBasicPropertyDescriptor(property); - Object.defineProperty(CSSStyleDeclaration.prototype, property, declaration); - Object.defineProperty(CSSStyleDeclaration.prototype, dashedToCamelCase(property), declaration); - } -}); - -exports.CSSStyleDeclaration = CSSStyleDeclaration; diff --git a/lib/CSSStyleDeclaration.test.js b/lib/CSSStyleDeclaration.test.js index 6828fa40..e2641090 100644 --- a/lib/CSSStyleDeclaration.test.js +++ b/lib/CSSStyleDeclaration.test.js @@ -1,20 +1,20 @@ 'use strict'; -var { CSSStyleDeclaration } = require('./CSSStyleDeclaration'); +var CSSStyleDeclaration = require('../index.js'); var allProperties = require('./allProperties'); var allExtraProperties = require('./allExtraProperties'); var implementedProperties = require('./implementedProperties'); -var parsers = require('./parsers'); +var { cssPropertyToIDLAttribute } = require('./parsers'); var dashedProperties = [...allProperties, ...allExtraProperties]; -var allowedProperties = dashedProperties.map(parsers.dashedToCamelCase); -implementedProperties = Array.from(implementedProperties).map(parsers.dashedToCamelCase); +var allowedProperties = ['cssFloat', ...dashedProperties.map((p) => cssPropertyToIDLAttribute(p))]; +implementedProperties = Array.from(implementedProperties, (p) => cssPropertyToIDLAttribute(p)); var invalidProperties = implementedProperties.filter((prop) => !allowedProperties.includes(prop)); describe('CSSStyleDeclaration', () => { test('has only valid properties implemented', () => { - expect(invalidProperties.length).toEqual(0); + expect(invalidProperties).toEqual([]); }); test('has all properties', () => { @@ -41,9 +41,6 @@ describe('CSSStyleDeclaration', () => { expect(typeof style.setProperty).toEqual('function'); expect(typeof style.getPropertyPriority).toEqual('function'); expect(typeof style.removeProperty).toEqual('function'); - - // TODO - deprecated according to MDN and not implemented at all, can we remove? - expect(typeof style.getPropertyCSSValue).toEqual('function'); }); test('has special properties', () => { @@ -52,7 +49,6 @@ describe('CSSStyleDeclaration', () => { expect(style.__lookupGetter__('cssText')).toBeTruthy(); expect(style.__lookupSetter__('cssText')).toBeTruthy(); expect(style.__lookupGetter__('length')).toBeTruthy(); - expect(style.__lookupSetter__('length')).toBeTruthy(); expect(style.__lookupGetter__('parentRule')).toBeTruthy(); }); diff --git a/lib/allExtraProperties.js b/lib/allExtraProperties.js index 44b9c296..1a4b8e0b 100644 --- a/lib/allExtraProperties.js +++ b/lib/allExtraProperties.js @@ -16,7 +16,6 @@ module.exports = new Set( 'color-interpolation', 'color-profile', 'color-rendering', - 'css-float', 'enable-background', 'fill', 'fill-opacity', diff --git a/lib/allWebkitProperties.js b/lib/allWebkitProperties.js index 0a74fa9c..f75551ec 100644 --- a/lib/allWebkitProperties.js +++ b/lib/allWebkitProperties.js @@ -191,4 +191,4 @@ module.exports = [ 'wrap-through', 'writing-mode', 'zoom', -].map((prop) => 'webkit-' + prop); +].map((prop) => '-webkit-' + prop); diff --git a/lib/parsers.js b/lib/parsers.js index 887bd516..00700251 100644 --- a/lib/parsers.js +++ b/lib/parsers.js @@ -448,22 +448,35 @@ exports.parseKeyword = function parseKeyword(val, valid_keywords) { return undefined; }; -// utility to translate from border-width to borderWidth -var dashedToCamelCase = function (dashed) { - var i; - var camel = ''; - var nextCap = false; - for (i = 0; i < dashed.length; i++) { - if (dashed[i] !== '-') { - camel += nextCap ? dashed[i].toUpperCase() : dashed[i]; - nextCap = false; +/** + * utility to translate from border-width to borderWidth + * + * @param {string} property + * @param {boolean} [lowercaseFirst] + * @see https://drafts.csswg.org/cssom/#css-property-to-idl-attribute + */ +function cssPropertyToIDLAttribute(property, lowercaseFirst = false) { + let output = ''; + let uppercaseNext = false; + + if (lowercaseFirst) { + property = property.substring(1); + } + + for (const c of property) { + if (c === '-') { + uppercaseNext = true; + } else if (uppercaseNext) { + uppercaseNext = false; + output += c.toUpperCase(); } else { - nextCap = true; + output += c; } } - return camel; -}; -exports.dashedToCamelCase = dashedToCamelCase; + + return output; +} +exports.cssPropertyToIDLAttribute = cssPropertyToIDLAttribute; var is_space = /\s/; var opening_deliminators = ['"', "'", '(']; @@ -567,7 +580,7 @@ exports.shorthandSetter = function (property, shorthand_for) { Object.keys(obj).forEach(function (subprop) { // in case subprop is an implicit property, this will clear // *its* subpropertiesX - var camel = dashedToCamelCase(subprop); + var camel = cssPropertyToIDLAttribute(subprop); this[camel] = obj[subprop]; // in case it gets translated into something else (0 -> 0px) obj[subprop] = this[camel]; @@ -721,15 +734,15 @@ exports.subImplicitSetter = function (prefix, part, isValid, parser) { }; }; -var camel_to_dashed = /[A-Z]/g; -var first_segment = /^\([^-]\)-/; -var vendor_prefixes = ['o', 'moz', 'ms', 'webkit']; -exports.camelToDashed = function (camel_case) { - var match; - var dashed = camel_case.replace(camel_to_dashed, '-$&').toLowerCase(); - match = dashed.match(first_segment); - if (match && vendor_prefixes.indexOf(match[1]) !== -1) { - dashed = '-' + dashed; - } - return dashed; +const camel_to_dashed = /[A-Z]/g; + +/** + * @param {string} attribute + * @param {boolean} [dashPrefix] + * @see https://drafts.csswg.org/cssom/#idl-attribute-to-css-property + */ +exports.idlAttributeToCSSProperty = (attribute, dashPrefix = false) => { + let output = dashPrefix ? '-' : ''; + output += attribute.replace(camel_to_dashed, '-$&').toLowerCase(); + return output; }; diff --git a/lib/parsers.test.js b/lib/parsers.test.js index 0ab78d19..a5a69455 100644 --- a/lib/parsers.test.js +++ b/lib/parsers.test.js @@ -123,7 +123,7 @@ describe('parseAngle', () => { describe('parseKeyword', () => { it.todo('test'); }); -describe('dashedToCamelCase', () => { +describe('cssPropertyToIDLAttribute', () => { it.todo('test'); }); describe('shorthandParser', () => { @@ -141,6 +141,6 @@ describe('implicitSetter', () => { describe('subImplicitSetter', () => { it.todo('test'); }); -describe('camelToDashed', () => { +describe('idlAttributeToCSSProperty', () => { it.todo('test'); }); diff --git a/lib/properties/borderSpacing.js b/lib/properties/borderSpacing.js index bf542e6c..43524af2 100644 --- a/lib/properties/borderSpacing.js +++ b/lib/properties/borderSpacing.js @@ -10,7 +10,7 @@ var parse = function parse(v) { if (v === '' || v === null) { return undefined; } - if (v === 0) { + if (v === '0' || v === 0) { return '0px'; } if (v.toLowerCase() === 'inherit') { diff --git a/lib/utils/getBasicPropertyDescriptor.js b/lib/utils/getBasicPropertyDescriptor.js deleted file mode 100644 index be0a9933..00000000 --- a/lib/utils/getBasicPropertyDescriptor.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -module.exports = function getBasicPropertyDescriptor(name) { - return { - set: function (v) { - this._setProperty(name, v); - }, - get: function () { - return this.getPropertyValue(name); - }, - enumerable: true, - configurable: true, - }; -}; diff --git a/package-lock.json b/package-lock.json index f2d31760..fc3ed174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "4.0.1", "license": "MIT", "dependencies": { - "rrweb-cssom": "^0.6.0" + "rrweb-cssom": "^0.6.0", + "webidl-conversions": "^7.0.0" }, "devDependencies": { "babel-generator": "^6.26.1", @@ -22,7 +23,8 @@ "jest": "^29.7.0", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", - "resolve": "^1.22.1" + "resolve": "^1.22.1", + "webidl2js": "^18.0.0" }, "engines": { "node": ">=18" @@ -7578,6 +7580,56 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/webidl2": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/webidl2/-/webidl2-24.4.1.tgz", + "integrity": "sha512-cPToqvZlxTAlaMucZyU28XtFLJz3XPdTdIWK/r3IaP1jfkjqne3OTniJS8DZqfzee1aBUQn80d6s2vYert50kg==", + "dev": true, + "license": "W3C", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl2js": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/webidl2js/-/webidl2js-18.0.0.tgz", + "integrity": "sha512-67EXwjCU0E7QaYAGEw5VldR/QiHBLA9EQxUB1Q7woM8+J3cz/jSw/xi+Q6O/v2QUhyZ5oiqaP9az4OXJB0bUZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier": "^2.8.8", + "webidl-conversions": "^7.0.0", + "webidl2": "^24.4.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl2js/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13358,6 +13410,36 @@ "makeerror": "1.0.12" } }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "webidl2": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/webidl2/-/webidl2-24.4.1.tgz", + "integrity": "sha512-cPToqvZlxTAlaMucZyU28XtFLJz3XPdTdIWK/r3IaP1jfkjqne3OTniJS8DZqfzee1aBUQn80d6s2vYert50kg==", + "dev": true + }, + "webidl2js": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/webidl2js/-/webidl2js-18.0.0.tgz", + "integrity": "sha512-67EXwjCU0E7QaYAGEw5VldR/QiHBLA9EQxUB1Q7woM8+J3cz/jSw/xi+Q6O/v2QUhyZ5oiqaP9az4OXJB0bUZQ==", + "dev": true, + "requires": { + "prettier": "^2.8.8", + "webidl-conversions": "^7.0.0", + "webidl2": "^24.4.1" + }, + "dependencies": { + "prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true + } + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e041af8d..c312ec5f 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,14 @@ "lib": "./lib" }, "files": [ + "index.js", + "webidl2js-wrapper.js", "lib/" ], - "main": "./lib/CSSStyleDeclaration.js", + "main": "./index.js", "dependencies": { - "rrweb-cssom": "^0.6.0" + "rrweb-cssom": "^0.6.0", + "webidl-conversions": "^7.0.0" }, "devDependencies": { "babel-generator": "^6.26.1", @@ -50,13 +53,16 @@ "jest": "^29.7.0", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", - "resolve": "^1.22.1" + "resolve": "^1.22.1", + "webidl2js": "^18.0.0" }, "scripts": { + "prepare": "npm run generate", "download": "node ./scripts/download_latest_properties.js && eslint lib/allProperties.js --fix", - "generate": "run-p generate:*", + "generate": "run-s generate:*", "generate:implemented_properties": "node ./scripts/generate_implemented_properties.js", "generate:properties": "node ./scripts/generate_properties.js", + "generate:webidl2js": "node ./scripts/convert-idl.js", "lint": "npm run generate && eslint . --max-warnings 0", "lint:fix": "eslint . --fix --max-warnings 0", "prepublishOnly": "npm run lint && npm run test", diff --git a/scripts/convert-idl.js b/scripts/convert-idl.js new file mode 100644 index 00000000..068cb713 --- /dev/null +++ b/scripts/convert-idl.js @@ -0,0 +1,107 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const Transformer = require('webidl2js'); + +const allProperties = require('../lib/allProperties.js'); +const allExtraProperties = require('../lib/allExtraProperties.js'); +const implementedProperties = require('../lib/implementedProperties.js'); +const { cssPropertyToIDLAttribute } = require('../lib/parsers.js'); + +const srcDir = path.resolve(__dirname, '../src'); +const implDir = path.resolve(__dirname, '../lib'); +const outputDir = implDir; + +const propertyNames = [ + ...allProperties, + ...Array.from(allExtraProperties).filter((prop) => { + return !allProperties.has(prop); + }), +].sort(); + +// TODO: This should be natively supported by WebIDL2JS's Transformer +// https://github.com/jsdom/webidl2js/issues/188 +const genIDL = fs.createWriteStream( + path.resolve(__dirname, '../src/CSSStyleDeclaration-properties.webidl'), + { + encoding: 'utf-8', + } +); + +{ + genIDL.write(`\ +// autogenerated by scripts/convert-idl.js. do not edit! ${new Date().toISOString()} + +partial interface CSSStyleDeclaration { + // https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-camel_cased_attribute +`); + + for (const property of propertyNames) { + const camelCasedAttribute = cssPropertyToIDLAttribute(property); + let extAttrs = 'CEReactions'; + if (!implementedProperties.has(property)) { + extAttrs += `,ReflectStyle="${property}"`; + } + genIDL.write(`\ + [${extAttrs}] attribute [LegacyNullToEmptyString] CSSOMString _${camelCasedAttribute}; +`); + } + + genIDL.write(` + // https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-webkit_cased_attribute +`); + + for (const property of propertyNames) { + if (!property.startsWith('-webkit-')) continue; + const webkitCasedAttribute = cssPropertyToIDLAttribute(property, /* lowercaseFirst = */ true); + let extAttrs = 'CEReactions'; + if (!implementedProperties.has(property)) { + extAttrs += `,ReflectStyle="${property}"`; + } + genIDL.write(`\ + [${extAttrs}] attribute [LegacyNullToEmptyString] CSSOMString _${webkitCasedAttribute}; +`); + } + + genIDL.write(` + // https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-dashed_attribute +`); + + for (const property of propertyNames) { + if (!property.includes('-')) continue; + let extAttrs = 'CEReactions'; + if (!implementedProperties.has(property)) { + extAttrs += `,ReflectStyle="${property}"`; + } + genIDL.write(`\ + [${extAttrs}] attribute [LegacyNullToEmptyString] CSSOMString ${property}; +`); + } + + genIDL.end('};\n'); +} + +const transformer = new Transformer({ + implSuffix: '-impl', + // TODO: Add support for `[CEReactions]` + processReflect(idl, implName) { + const reflectStyle = idl.extAttrs.find((extAttr) => extAttr.name === 'ReflectStyle'); + if (!reflectStyle || !reflectStyle.rhs || reflectStyle.rhs.type !== 'string') { + throw new Error(`Internal error: Invalid [ReflectStyle] for attribute ${idl.name}`); + } + + return { + get: `return ${implName}.getPropertyValue(${reflectStyle.rhs.value});`, + set: `${implName}._setProperty(${reflectStyle.rhs.value}, V);`, + }; + }, +}); + +transformer.addSource(srcDir, implDir); +new Promise((resolve) => genIDL.on('finish', resolve)) + .then(() => transformer.generate(outputDir)) + .catch((err) => { + console.error(err.stack); + process.exit(1); + }); diff --git a/scripts/download_latest_properties.js b/scripts/download_latest_properties.js index d63fa2be..c33f594b 100644 --- a/scripts/download_latest_properties.js +++ b/scripts/download_latest_properties.js @@ -21,7 +21,7 @@ var fs = require('fs'); var path = require('path'); -const { camelToDashed } = require('../lib/parsers'); +const { idlAttributeToCSSProperty } = require('../lib/parsers'); var url = 'https://www.w3.org/Style/CSS/all-properties.en.json'; @@ -71,7 +71,11 @@ async function main() { out_file.write('/*\n *\n * https://www.w3.org/Style/CSS/all-properties.en.html\n */\n\n'); out_file.write( 'module.exports = new Set(' + - JSON.stringify(CSSpropertyNames.map(camelToDashed), null, 2) + + JSON.stringify( + CSSpropertyNames.map((p) => idlAttributeToCSSProperty(p)), + null, + 2 + ) + ');\n' ); diff --git a/scripts/generate_implemented_properties.js b/scripts/generate_implemented_properties.js index 914835e7..8b107322 100644 --- a/scripts/generate_implemented_properties.js +++ b/scripts/generate_implemented_properties.js @@ -4,12 +4,18 @@ const fs = require('fs'); const path = require('path'); const t = require('babel-types'); const generate = require('babel-generator').default; -const camelToDashed = require('../lib/parsers').camelToDashed; +const { idlAttributeToCSSProperty } = require('../lib/parsers'); +const webkitPropertyName = /^webkit[A-Z]/; const dashedProperties = fs .readdirSync(path.resolve(__dirname, '../lib/properties')) - .filter((propertyFile) => propertyFile.substr(-3) === '.js') - .map((propertyFile) => camelToDashed(propertyFile.replace('.js', ''))); + .filter((propertyFile) => path.extname(propertyFile) === '.js') + .map((propertyFile) => { + return idlAttributeToCSSProperty( + path.basename(propertyFile, '.js'), + /* dashPrefix = */ webkitPropertyName.test(propertyFile) + ); + }); const out_file = fs.createWriteStream(path.resolve(__dirname, '../lib/implementedProperties.js'), { encoding: 'utf-8', diff --git a/scripts/generate_properties.js b/scripts/generate_properties.js index eabfdb1d..5af9754a 100644 --- a/scripts/generate_properties.js +++ b/scripts/generate_properties.js @@ -8,7 +8,7 @@ var generate = require('babel-generator').default; var traverse = require('babel-traverse').default; var resolve = require('resolve'); -var camelToDashed = require('../lib/parsers').camelToDashed; +var { idlAttributeToCSSProperty, cssPropertyToIDLAttribute } = require('../lib/parsers'); var basename = path.basename; var dirname = path.dirname; @@ -56,12 +56,17 @@ function isRequire(node, filename) { } } +const webkitPropertyName = /^webkit[A-Z]/; + // step 1: parse all files and figure out their dependencies var parsedFilesByPath = {}; property_files.map(function (property) { var filename = path.resolve(__dirname, '../lib/properties/' + property); var src = fs.readFileSync(filename, 'utf8'); property = basename(property, '.js'); + if (webkitPropertyName.test(property)) { + property = property[0].toUpperCase() + property.substring(1); + } var ast = babylon.parse(src); var dependencies = []; traverse(ast, { @@ -251,13 +256,21 @@ parsedFiles.forEach(function (file) { }); var propertyDefinitions = []; parsedFiles.forEach(function (file) { - var dashed = camelToDashed(file.property); + var dashed = idlAttributeToCSSProperty(file.property); propertyDefinitions.push( t.objectProperty( t.identifier(file.property), t.identifier(file.property + '_export_definition') ) ); + if (dashed.startsWith('-webkit-')) { + propertyDefinitions.push( + t.objectProperty( + t.identifier(cssPropertyToIDLAttribute(dashed, /* lowercaseFirst = */ true)), + t.identifier(file.property + '_export_definition') + ) + ); + } if (file.property !== dashed) { propertyDefinitions.push( t.objectProperty(t.stringLiteral(dashed), t.identifier(file.property + '_export_definition')) diff --git a/src/CSSStyleDeclaration.webidl b/src/CSSStyleDeclaration.webidl new file mode 100644 index 00000000..1e6447ee --- /dev/null +++ b/src/CSSStyleDeclaration.webidl @@ -0,0 +1,18 @@ +// https://drafts.csswg.org/cssom/#cssomstring +typedef DOMString CSSOMString; + +// https://drafts.csswg.org/cssom/#cssstyledeclaration +[Exposed=Window] +interface CSSStyleDeclaration { + [CEReactions] attribute CSSOMString cssText; + readonly attribute unsigned long length; + getter CSSOMString item(unsigned long index); + CSSOMString getPropertyValue(CSSOMString property); + CSSOMString getPropertyPriority(CSSOMString property); + [CEReactions] void setProperty(CSSOMString property, [LegacyNullToEmptyString] CSSOMString value, optional [LegacyNullToEmptyString] CSSOMString priority = ""); + [CEReactions] CSSOMString removeProperty(CSSOMString property); + readonly attribute CSSRule? parentRule; + [CEReactions, ReflectStyle="float"] attribute [LegacyNullToEmptyString] CSSOMString cssFloat; +}; + +// Additional partial interfaces are generated by `scripts/convert-idl.js`. diff --git a/webidl2js-wrapper.js b/webidl2js-wrapper.js new file mode 100644 index 00000000..0c6f5215 --- /dev/null +++ b/webidl2js-wrapper.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('./lib/CSSStyleDeclaration.js');