Add a DOM pinned properties dataset generator
Signed-off-by: Victor Porof <victorporof@chromium.org>
Bug: 1325812
Change-Id: I292d49d273d74d33a82e25179478405395159537
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3695372
Reviewed-by: Mathias Bynens <mathias@chromium.org>
diff --git a/scripts/deps/roll_deps.py b/scripts/deps/roll_deps.py
index 7c1eb05..21c9b70 100755
--- a/scripts/deps/roll_deps.py
+++ b/scripts/deps/roll_deps.py
@@ -81,8 +81,18 @@
])
+def generate_dom_pinned_properties(options):
+ print('generating DOM pinned properties dataset from .idl definitions')
+ subprocess.check_call([
+ node_path(options),
+ os.path.join(options.devtools_dir, 'scripts', 'webidl-properties',
+ 'index.js'), options.devtools_dir
+ ])
+
+
if __name__ == '__main__':
OPTIONS = parse_options(sys.argv[1:])
update(OPTIONS)
copy_files(OPTIONS)
generate_signatures(OPTIONS)
+ generate_dom_pinned_properties(OPTIONS)
diff --git a/scripts/webidl-properties/config.js b/scripts/webidl-properties/config.js
new file mode 100644
index 0000000..ceddb72
--- /dev/null
+++ b/scripts/webidl-properties/config.js
@@ -0,0 +1,279 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * All the specs relevant for generating the DOM pinned properties dataset.
+ * These strings represent file names. A list of all file names can be found at
+ * https://github.com/w3c/webref/tree/main/tr/idl or via the @webref/idl API.
+ *
+ * These spec names are included in the generated dataset. To keep it small,
+ * the values are bitwise or'd.
+ */
+export const SPECS = {
+ 'html': 1,
+ 'dom': 2,
+ 'uievents': 4,
+ 'pointerevents': 8,
+ 'cssom': 16,
+ 'wai-aria': 32
+};
+
+/**
+ * All the "global" attributes as defined in the DOM specification.
+ * Used to annotate the extracted properties from the WebIDL types.
+ */
+export const GLOBAL_ATTRIBUTES = new Set([
+ // https://html.spec.whatwg.org/multipage/dom.html#global-attributes
+ 'accesskey', 'autocapitalize', 'autofocus', 'contenteditable', 'dir', 'draggable', 'enterkeyhint',
+ 'hidden', 'inputmode', 'is', 'itemid', 'itemprop', 'itemref', 'itemscope',
+ 'itemtype', 'lang', 'nonce', 'spellcheck', 'style', 'tabindex', 'title',
+ 'translate',
+]);
+
+/**
+ * The "applicable" members for certain "states" that WebIDL types can be in.
+ * In other words, some members are "valid" only valid in certain situations:
+ * for example, with the HTML input element, the set of valid members are
+ * determined by the "type" attribute.
+ */
+export const VALID_MEMBERS = {
+ // https://html.spec.whatwg.org/multipage/input.html#input-type-attr-summary
+ HTMLInputElement: {
+ '[type=hidden]': new Set([
+ 'autocomplete',
+ 'value',
+ ]),
+ '[type=text]': new Set([
+ 'autocomplete',
+ 'dirname',
+ 'list',
+ 'maxlength',
+ 'minlength',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'list',
+ 'selectionstart',
+ 'selectionend',
+ 'selectiondirection',
+ ]),
+ '[type=search]': new Set([
+ 'autocomplete',
+ 'dirname',
+ 'list',
+ 'maxlength',
+ 'minlength',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'list',
+ 'selectionstart',
+ 'selectionend',
+ 'selectiondirection',
+ ]),
+ '[type=url]': new Set([
+ 'autocomplete',
+ 'list',
+ 'maxlength',
+ 'minlength',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'list',
+ 'selectionstart',
+ 'selectionend',
+ 'selectiondirection',
+ ]),
+ '[type=tel]': new Set([
+ 'autocomplete',
+ 'list',
+ 'maxlength',
+ 'minlength',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'list',
+ 'selectionstart',
+ 'selectionend',
+ 'selectiondirection',
+ ]),
+ '[type=email]': new Set([
+ 'autocomplete',
+ 'list',
+ 'maxlength',
+ 'minlength',
+ 'multiple',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'list',
+ ]),
+ '[type=password]': new Set([
+ 'autocomplete',
+ 'maxlength',
+ 'minlength',
+ 'pattern',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'size',
+ 'value',
+ 'selectionstart',
+ 'selectionend',
+ 'selectiondirection',
+ ]),
+ '[type=date]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasdate',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=month]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasdate',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=week]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasdate',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=time]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasdate',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=datetime-local]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=number]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'placeholder',
+ 'readonly',
+ 'required',
+ 'step',
+ 'value',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=range]': new Set([
+ 'autocomplete',
+ 'list',
+ 'max',
+ 'min',
+ 'step',
+ 'value',
+ 'valueasnumber',
+ 'list',
+ ]),
+ '[type=color]': new Set([
+ 'autocomplete',
+ 'list',
+ 'value',
+ ]),
+ '[type=checkbox]': new Set([
+ 'checked',
+ 'required',
+ 'checked',
+ 'value',
+ ]),
+ '[type=radio]': new Set([
+ 'checked',
+ 'required',
+ 'checked',
+ 'value',
+ ]),
+ '[type=file]': new Set([
+ 'accept',
+ 'multiple',
+ 'required',
+ 'files',
+ 'value',
+ ]),
+ '[type=submit]': new Set([
+ 'formaction',
+ 'formenctype',
+ 'formmethod',
+ 'formnovalidate',
+ 'formtarget',
+ 'value',
+ ]),
+ '[type=image]': new Set([
+ 'alt',
+ 'formaction',
+ 'formenctype',
+ 'formmethod',
+ 'formnovalidate',
+ 'formtarget',
+ 'height',
+ 'src',
+ 'width',
+ 'value',
+ ]),
+ '[type=reset]': new Set([
+ 'value',
+ ]),
+ '[type=button]': new Set([
+ 'value',
+ ]),
+ },
+};
diff --git a/scripts/webidl-properties/get-props.js b/scripts/webidl-properties/get-props.js
new file mode 100644
index 0000000..75d8796
--- /dev/null
+++ b/scripts/webidl-properties/get-props.js
@@ -0,0 +1,139 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {GLOBAL_ATTRIBUTES, SPECS, VALID_MEMBERS} from './config.js';
+import {merge} from './util.js';
+
+/**
+ * All the members relevant for generating the DOM pinned properties dataset
+ * from WebIDL interfaces, mixins and dictionaries.
+ */
+const ACCEPTED_MEMBER_TYPES = new Set(['attribute', 'field']);
+
+/**
+ * Generates the DOM pinned properties dataset.
+ *
+ * @param {array} specs A list of specs. Each spec specifies its name and
+ * all the idl definitions it contains.
+ * @returns {object} output An object with WebIDL type names as keys and their
+ * WebIDL properties and inheritance/include chains as values.
+ */
+export function getIDLProps(specs, output = {}) {
+ for (const spec of specs) {
+ transform(spec, output);
+ }
+ return output;
+}
+
+function transform({name, idls}, output = {}) {
+ const makeEntry = () => ({
+ inheritance: null,
+ includes: [],
+ props: {},
+ });
+
+ for (const idl of idls) {
+ switch (idl.type) {
+ case 'interface':
+ case 'interface mixin':
+ case 'dictionary': {
+ output[idl.name] = output[idl.name] ?? makeEntry();
+ let props = idl.members?.filter(member => ACCEPTED_MEMBER_TYPES.has(member.type));
+ props = props?.map(member => [member.name, {global: GLOBAL_ATTRIBUTES.has(member.name), specs: [name]}, ]);
+ merge(output[idl.name], {
+ inheritance: idl.inheritance,
+ props: Object.fromEntries(props),
+ });
+ break;
+ }
+ case 'includes': {
+ output[idl.target] = output[idl.target] ?? makeEntry();
+ merge(output[idl.target], {
+ includes: [idl.includes],
+ });
+ break;
+ }
+ case 'callback':
+ case 'callback interface':
+ case 'enum':
+ case 'typedef':
+ case 'namespace': {
+ break;
+ }
+ default: {
+ console.warn('Skipping unknown WebIDL type', idl.type);
+ }
+ }
+ }
+}
+
+/**
+ * Adds additional metadata to the DOM pinned properties dataset.
+ *
+ * Currently only adds information about which properties are valid based on
+ * some state, such as for the HTMLInputElement. See `VALID_MEMBERS`.
+ *
+ * @param {*} output
+ */
+export function addMetadata(output) {
+ for (const [key, value] of Object.entries(output)) {
+ const rule = VALID_MEMBERS[key];
+ if (!rule) {
+ continue;
+ }
+ const states = Object.entries(rule).map(([selector, allowlist]) => {
+ const valid = Object.entries(value.props).filter(([prop]) => allowlist.has(prop.toLowerCase()));
+ return [selector, Object.fromEntries(valid)];
+ });
+ value.states = Object.fromEntries(states);
+ }
+ return output;
+}
+
+/**
+ * Minimizes the DOM pinned properties dataset to remove the bits of data that
+ * don't contain information. For example, empty inheritance/includes chains.
+ *
+ * This should be done right at the end, before writing into the output file, to
+ * allow for certain diagnostics (such as finding "missing types").
+ *
+ * @param {*} output
+ * @returns {object}
+ */
+export function minimize(output) {
+ for (const [key, value] of Object.entries(output)) {
+ if (!value.inheritance) {
+ // Remove empty inheritance chains.
+ delete value.inheritance;
+ }
+ if (!value.includes.length) {
+ // Remove empty include chains.
+ delete value.includes;
+ }
+ const props = Object.entries(value.props);
+ if (!props.length) {
+ // Remove empty 'prop' lists.
+ delete value.props;
+ } else {
+ for (const [, value] of props) {
+ if (!value.global) {
+ // Remove the 'global' flag if it's false.
+ delete value.global;
+ }
+ if (value.specs.length === 1 && value.specs[0] === 'html') {
+ // Remove the 'specs' list if it's just "html".
+ delete value.specs;
+ } else {
+ // Combine multiple spec names into a single value.
+ value.specs = value.specs.reduce((acc, name) => acc | SPECS[name], 0);
+ }
+ }
+ }
+ // Remove the entire entry if there's nothing left after the cleanup above.
+ if (!Object.entries(value).length) {
+ delete output[key];
+ }
+ }
+ return output;
+}
diff --git a/scripts/webidl-properties/index.js b/scripts/webidl-properties/index.js
new file mode 100644
index 0000000..ca78bc8
--- /dev/null
+++ b/scripts/webidl-properties/index.js
@@ -0,0 +1,85 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import idl from '@webref/idl';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as url from 'url';
+
+import {SPECS} from './config.js';
+import {addMetadata, getIDLProps, minimize} from './get-props.js';
+import {getMissingTypes} from './util.js';
+
+if (process.argv.length !== 3) {
+ throw new Error('Please provide the path to devtools-frontend');
+}
+
+const files = await idl.listAll();
+const names = Object.keys(SPECS);
+const specs = await Promise.all(names.map(name => files[name].parse().then(idls => ({name, idls}))));
+
+const output = addMetadata(getIDLProps(specs));
+const missing = getMissingTypes(output);
+
+for (const type of missing) {
+ console.warn('Found missing type:', type);
+}
+
+const frontendPath = path.resolve(process.argv[2]);
+const jsMetadataPath = path.join(frontendPath, 'front_end/models/javascript_metadata/');
+const outPath = path.join(jsMetadataPath, 'DOMPinnedProperties.ts');
+const thisPath = path.relative(frontendPath, url.fileURLToPath(import.meta.url));
+
+fs.writeFileSync(outPath, `
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Generated from ${thisPath}
+
+/**
+ * All the specs used when generating the DOM pinned properties dataset.
+ */
+export const SPECS = ${JSON.stringify(SPECS)};
+
+export interface DOMPinnedWebIDLProp {
+ // A flag specifying whether it's a "global" attribute.
+ global?: boolean;
+ // A bitfield of the specs in which the property is found.
+ // If missing, it implies the default spec: "html".
+ specs?: number;
+}
+
+export interface DOMPinnedWebIDLType {
+ // An inherited Type.
+ inheritance?: string;
+ // A set of Types to also include properties from.
+ includes?: Array<string>;
+ // The properties defined on this Type.
+ props?: {
+ // A property name such as "checked".
+ [PropName: string]: DOMPinnedWebIDLProp,
+ };
+ // The "states" in which only certain properties are "applicable".
+ states?: {
+ // A CSS selector such as "[type=checkbox]".
+ [State: string]: {
+ [PropName: string]: DOMPinnedWebIDLProp,
+ },
+ };
+}
+
+export interface DOMPinnedPropertiesDataset {
+ [TypeName: string]: DOMPinnedWebIDLType;
+}
+
+/**
+ * The DOM pinned properties dataset. Generated from WebIDL data parsed from
+ * the SPECS above.
+ *
+ * This is an object with WebIDL type names as keys and their WebIDL properties
+ * and inheritance/include chains as values.
+ */
+export const DOMPinnedProperties: DOMPinnedPropertiesDataset = ${JSON.stringify(minimize(output))};
+`);
diff --git a/scripts/webidl-properties/package.json b/scripts/webidl-properties/package.json
new file mode 100644
index 0000000..e746aa7
--- /dev/null
+++ b/scripts/webidl-properties/package.json
@@ -0,0 +1,9 @@
+{
+ "private": true,
+ "main": "index.js",
+ "type": "module",
+ "scripts": {
+ "start": "node index.js",
+ "test": "mocha tests.js"
+ }
+}
diff --git a/scripts/webidl-properties/tests.js b/scripts/webidl-properties/tests.js
new file mode 100644
index 0000000..7301840
--- /dev/null
+++ b/scripts/webidl-properties/tests.js
@@ -0,0 +1,118 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import idl from '@webref/idl';
+import * as assert from 'assert';
+
+import {SPECS} from './config.js';
+import {addMetadata, getIDLProps, minimize} from './get-props.js';
+import {getMissingTypes} from './util.js';
+
+describe('DOM pinned properties dataset generation', function() {
+ let output;
+
+ this.beforeEach(async () => {
+ const files = await idl.listAll();
+ const names = Object.keys(SPECS);
+ const specs = await Promise.all(names.map(name => files[name].parse().then(idls => ({name, idls}))));
+ output = addMetadata(getIDLProps(specs));
+ });
+
+ it('doesn\'t have missing types', () => {
+ const missing = getMissingTypes(output);
+ assert.strictEqual(missing.length, 0);
+ });
+
+ it('generates valid data for HTMLElement', () => {
+ const type = output.HTMLElement;
+ assert.strictEqual(type.inheritance, 'Element');
+ assert.deepEqual(type.includes, [
+ 'GlobalEventHandlers',
+ 'DocumentAndElementEventHandlers',
+ 'ElementContentEditable',
+ 'HTMLOrSVGElement',
+ 'ElementCSSInlineStyle',
+ ]);
+ assert.deepEqual(type.props.title, {
+ global: true,
+ specs: ['html'],
+ });
+ assert.strictEqual(type.states, undefined);
+ });
+
+ it('generates valid data for HTMLInputElement', () => {
+ const type = output.HTMLInputElement;
+ assert.strictEqual(type.inheritance, 'HTMLElement');
+ assert.deepEqual(type.includes, []);
+ assert.deepEqual(type.props.checked, {
+ global: false,
+ specs: ['html'],
+ });
+ assert.deepEqual(type.states['[type=checkbox]'], {
+ checked: {global: false, specs: ['html']},
+ required: {global: false, specs: ['html']},
+ value: {global: false, specs: ['html']},
+ });
+ });
+
+ it('generates valid data for MouseEvent', () => {
+ const type = output.MouseEvent;
+ assert.strictEqual(type.inheritance, 'UIEvent');
+ assert.deepEqual(type.includes, []);
+ assert.deepEqual(type.props.screenX, {
+ global: false,
+ specs: ['uievents'],
+ });
+ assert.strictEqual(type.states, undefined);
+ });
+
+ it('generates valid data for PointerEvent', () => {
+ const type = output.PointerEvent;
+ assert.strictEqual(type.inheritance, 'MouseEvent');
+ assert.deepEqual(type.includes, []);
+ assert.deepEqual(type.props.pressure, {
+ global: false,
+ specs: ['pointerevents'],
+ });
+ assert.strictEqual(type.states, undefined);
+ });
+
+ it('generates an entry for DOMParser', () => {
+ const type = output.DOMParser;
+ assert.strictEqual(type.inheritance, null);
+ assert.deepEqual(type.includes, []);
+ assert.deepEqual(type.props, {});
+ assert.strictEqual(type.states, undefined);
+ });
+
+ it('minimizes the data for HTMLInputElement', () => {
+ const minimized = minimize(output);
+ const type = minimized.HTMLInputElement;
+ assert.strictEqual(type.inheritance, 'HTMLElement');
+ assert.strictEqual(type.includes, undefined);
+ assert.deepEqual(type.props.checked, {});
+ assert.deepEqual(type.states['[type=checkbox]'], {
+ checked: {},
+ required: {},
+ value: {},
+ });
+ });
+
+ it('minimizes the data for PointerEvent', () => {
+ const minimized = minimize(output);
+ const type = minimized.PointerEvent;
+ assert.strictEqual(type.inheritance, 'MouseEvent');
+ assert.strictEqual(type.includes, undefined);
+ assert.deepEqual(type.props.pressure, {
+ specs: 8,
+ });
+ assert.strictEqual(type.states, undefined);
+ });
+
+ it('removes the entry for DOMParser in the minimized output', () => {
+ const minimized = minimize(output);
+ assert.strictEqual(minimized.DOMParser, undefined);
+ });
+});
diff --git a/scripts/webidl-properties/util.js b/scripts/webidl-properties/util.js
new file mode 100644
index 0000000..f4e94e9
--- /dev/null
+++ b/scripts/webidl-properties/util.js
@@ -0,0 +1,80 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * Merges objects or arrays of objects. This is a simplistic merge operation
+ * that is only useful for generating the DOM pinned properties dataset.
+ *
+ * The merge happens in-place: b is merged *into* a.
+ * Both objects must be of the same type.
+ * Arrays are merged as unions with simple same-value-zero equality.
+ * Objects are merged with truthy-property precedence.
+ *
+ * @param {array|object} a
+ * @param {array|object} b
+ */
+export function merge(a, b) {
+ if (Array.isArray(a) && Array.isArray(b)) {
+ mergeArrays(a, b);
+ } else if (isNonNullObject(a) && isNonNullObject(b)) {
+ mergeObjects(a, b);
+ } else {
+ throw Error;
+ }
+
+ function isNonNullObject(value) {
+ return typeof value === 'object' && value !== null;
+ }
+
+ function mergeArrays(a, b) {
+ for (const value of b) {
+ if (!a.includes(value)) {
+ a.push(value);
+ }
+ }
+ }
+
+ function mergeObjects(a, b) {
+ for (const key of Object.keys(b)) {
+ if (isNonNullObject(a[key]) && isNonNullObject(b[key])) {
+ merge(a[key], b[key]);
+ } else {
+ a[key] = a[key] ?? b[key];
+ }
+ }
+ }
+}
+
+/**
+ * Finds "missing" types in a DOM pinned properties dataset.
+ * A "missing" type is defined as a type that is inherited or included by/in
+ * another type, but for which a definition wasn't found in the specs.
+ *
+ * This is a helper which helps to ensure that all relevant specs are parsed.
+ * E.g. some specs might reference types defined in other specs.
+ *
+ * @param {object} data
+ * @returns {array}
+ */
+export function getMissingTypes(data) {
+ const missing = new Set();
+ const keys = new Set(Object.keys(data));
+
+ for (const value of Object.values(data)) {
+ if (value.inherits) {
+ if (!keys.has(value.inherits)) {
+ missing.add(value.inherits);
+ }
+ }
+ if (value.includes) {
+ for (const include of value.includes) {
+ if (!keys.has(include)) {
+ missing.add(include);
+ }
+ }
+ }
+ }
+
+ return [...missing];
+}