From 0be8c1d08964985f575d54cbdaeba9af2766beb9 Mon Sep 17 00:00:00 2001 From: Dominique Hazael-Massieux Date: Fri, 26 Jun 2020 12:22:10 +0200 Subject: [PATCH 1/5] Add parsing of definitions specific to HTML spec The HTML spec does not follow the markup conventions to properly type and associate definitions, so it needs special handling. A lot is encoded in the ids of the definition elements, but not all the data (e.g. no distinction between attributes and methods), and there are many exceptions to the general conventions on id building. Figuring out the right output requires additional knowledge that is available through the WebIDL fragments embedded in the spec, so we make use of that. --- src/browserlib/extract-dfns.js | 505 +++++++++++++++++++++++++++++---- src/lib/util.js | 5 + tests/extract-dfns.js | 280 +++++++++++++++++- 3 files changed, 720 insertions(+), 70 deletions(-) diff --git a/src/browserlib/extract-dfns.js b/src/browserlib/extract-dfns.js index bf861b3a..988c35d6 100644 --- a/src/browserlib/extract-dfns.js +++ b/src/browserlib/extract-dfns.js @@ -19,72 +19,451 @@ * @public * @return {Array(Object)} An Array of definitions */ -export default function () { - const definitionsSelector = [ - 'dfn[id]', - 'h2[id][data-dfn-type]', - 'h3[id][data-dfn-type]', - 'h4[id][data-dfn-type]', - 'h5[id][data-dfn-type]', - 'h6[id][data-dfn-type]' - ].join(','); +function definitionMapper(el) { function normalize(str) { return str.trim().replace(/\s+/g, ' '); } + return { + // ID is the id attribute + id: el.getAttribute('id'), + + // Compute the absolute URL + // (Note the crawler merges pages of a multi-page spec in the first page + // to ease parsing logic, and we want to get back to the URL of the page) + href: (_ => { + const pageWrapper = el.closest('[data-reffy-page]'); + const url = new URL(pageWrapper ? + pageWrapper.getAttribute('data-reffy-page') : window.location.href); + url.hash = '#' + el.getAttribute('id'); + return url.toString(); + })(), + + // Linking text is given by the data-lt attribute if present, or it is the + // textual content + linkingText: el.hasAttribute('data-lt') ? + el.getAttribute('data-lt').split('|').map(normalize) : + [normalize(el.textContent)], + + // Additional linking text can be defined for local references + localLinkingText: el.getAttribute('data-local-lt') ? + el.getAttribute('data-local-lt').split('|').map(normalize) : + [], + + // Link type must be specified, or it is "dfn" + type: el.getAttribute('data-dfn-type') || 'dfn', + + // Definition may be namespaced to other constructs. Note the list is not + // purely comma-separated due to function parameters. For instance, + // attribute value may be "method(foo,bar), method()" + for: el.getAttribute('data-dfn-for') ? + el.getAttribute('data-dfn-for').split(/,(?![^\(]*\))/).map(normalize) : + [], + + // Definition is public if explictly marked as exportable or if export has + // not been explicitly disallowed and its type is not "dfn" + access: (el.hasAttribute('data-export') || + (!el.hasAttribute('data-noexport') && + el.hasAttribute('data-dfn-type') && + el.getAttribute('data-dfn-type') !== 'dfn')) ? + 'public' : 'private', + + // Whether the term is defined in a normative/informative section, + // provided the wrapping section follows usual patterns: + // https://github.com/w3c/respec/blob/develop/src/core/utils.js#L69 + // https://tabatkins.github.io/bikeshed/#metadata-informative-classes + informative: !!el.closest([ + '.informative', '.note', '.issue', '.example', '.ednote', '.practice', + '.introductory', '.non-normative' + + ].join(',')) + }; +} + +export default function (spec) { + const definitionsSelector = [ + // re data-lt, see https://github.com/tidoust/reffy/issues/336#issuecomment-650339747 + 'dfn[id]:not([data-lt=""])', + 'h2[id][data-dfn-type]:not([data-lt=""])', + 'h3[id][data-dfn-type]:not([data-lt=""])', + 'h4[id][data-dfn-type]:not([data-lt=""])', + 'h5[id][data-dfn-type]:not([data-lt=""])', + 'h6[id][data-dfn-type]:not([data-lt=""])' + ].join(','); + + let extraDefinitions = []; + + if (spec === "html") { + preProcessHTML(); + } + return [...document.querySelectorAll(definitionsSelector)] - .map(el => Object.assign({ - // ID is the id attribute - id: el.getAttribute('id'), - - // Compute the absolute URL - // (Note the crawler merges pages of a multi-page spec in the first page - // to ease parsing logic, and we want to get back to the URL of the page) - href: (_ => { - const pageWrapper = el.closest('[data-reffy-page]'); - const url = new URL(pageWrapper ? - pageWrapper.getAttribute('data-reffy-page') : window.location.href); - url.hash = '#' + el.getAttribute('id'); - return url.toString(); - })(), - - // Linking text is given by the data-lt attribute if present, or it is the - // textual content - linkingText: el.hasAttribute('data-lt') ? - el.getAttribute('data-lt').split('|').map(normalize) : - [normalize(el.textContent)], - - // Additional linking text can be defined for local references - localLinkingText: el.getAttribute('data-local-lt') ? - el.getAttribute('data-local-lt').split('|').map(normalize) : - [], - - // Link type must be specified, or it is "dfn" - type: el.getAttribute('data-dfn-type') || 'dfn', - - // Definition may be namespaced to other constructs. Note the list is not - // purely comma-separated due to function parameters. For instance, - // attribute value may be "method(foo,bar), method()" - for: el.getAttribute('data-dfn-for') ? - el.getAttribute('data-dfn-for').split(/,(?![^\(]*\))/).map(normalize) : - [], - - // Definition is public if explictly marked as exportable or if export has - // not been explicitly disallowed and its type is not "dfn" - access: (el.hasAttribute('data-export') || - (!el.hasAttribute('data-noexport') && - el.hasAttribute('data-dfn-type') && - el.getAttribute('data-dfn-type') !== 'dfn')) ? - 'public' : 'private', - - // Whether the term is defined in a normative/informative section, - // provided the wrapping section follows usual patterns: - // https://github.com/w3c/respec/blob/develop/src/core/utils.js#L69 - // https://tabatkins.github.io/bikeshed/#metadata-informative-classes - informative: !!el.closest([ - '.informative', '.note', '.issue', '.example', '.ednote', '.practice', - '.introductory', '.non-normative' - ].join(',')) - })); -} \ No newline at end of file + .map(definitionMapper); +} + +function preProcessHTML() { + // We need to extract the list of possible interfaces by parsing the WebIDL of the spec first + const idl = window.reffy.extractWebIdl(); + const idlTree = WebIDL2.parse(idl); + const idlInterfaces = idlTree.filter(item => item.type === "interface" || item.type === "interface mixin"); + + function fromIdToElement(id) { + switch(id) { + case "hyperlink": return "a,area"; + case "mod": return "ins,del"; + case "dim": return "img,iframe,embed,object,video"; + // The spec lists img, but img doesn't have a form attribute + case "fae": return "button,fieldset,input,object,output,select,textarea"; + case "fe": return "button,fieldset,input,object,output,select,textarea"; + case "fs": return "form,button"; + case "hx": return "h1,h2,h3,h4,h5,h6"; + case "tdth": return "td,th"; + // xml: attributes are id'd as xml- + // case "xml": return "all HTML elements"; + case "xml": return undefined; + + }; + return id; + } + + function fromIdToIdl(id) { + const specialInterfaceIds = { + "appcache": "ApplicationCache", + "a": "HTMLAnchorElement", + "caption": "HTMLTableCaptionElement", + "colgroup": "HTMLTableColElement", + "col": "HTMLTableColElement", + "context-2d-canvas": "CanvasRenderingContext2D", + // submittable elements https://html.spec.whatwg.org/multipage/forms.html#category-submit + "cva": "HTMLButtonElement,HTMLInputElement,HTMLObjectElement,HTMLSelectElement,HTMLTextAreaElement", + "dnd": "GlobalEventHandlers", + "dim": "HTMLImageElement,HTMLIFrameElement,HTMLEmbedElement,HTMLObjectElement,HTMLVideoElement", + "dir": "HTMLDirectoryElement", + "dl": "HTMLDListElement", + // form associated elements https://html.spec.whatwg.org/multipage/forms.html#form-associated-element + // The spec lists img, but img doesn't have a form attribute + "fae": "HTMLButtonElement,HTMLFieldsetElement,HTMLInputElement,HTMLObjectElement,HTMLOutputElement,HTMLSelectElement,HTMLTextAreaElement", + // form elements https://html.spec.whatwg.org/multipage/forms.html#category-listed + "fe": "HTMLButtonElement,HTMLFieldsetElement,HTMLInputElement,HTMLSelectElement,HTMLTextAreaElement", + // Form submission attributes https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-attributes + // some are for button some for form + "fs": "HTMLButtonElement,HTMLFormElement", + "hx": "HTMLHeadingElement", + "hyperlink": "HTMLHyperlinkElementUtils", + "img": "HTMLImageElement", + // Labelable form elements + "lfe": "HTMLButtonElement,HTMLInputElement,HTMLMeterElement,HTMLOutputElement,HTMLProgressElement,HTMLSelectElement,HTMLTextAreaElement", + "ol": "HTMLOListElement", + "p": "HTMLParagraphElement", + "tbody": "HTMLTableSectionElement", + "td": "HTMLTableCellElement", + "th": "HTMLTableCellElement", + "tdth": "HTMLTableCellElement", + "textarea/input": "HTMLTextAreaElement,HTMLInputElement", + "tr": "HTMLTableRowElement", + "tracklist": "AudioTrackList,VideoTrackList", + "ul": "HTMLUListElement" + }; + if (specialInterfaceIds[id]) { + return specialInterfaceIds[id]; + } + let iface = idlInterfaces.find(i => i.name.toLowerCase() === id || i.name.toLowerCase() === `html${id}element`); + if (iface) { + return iface.name; + } + } + + function fromIdToTypeAndFor(containerid, id) { + // deals with exceptions to how containerid / id are expected to be parsed + if (id) { + [containerid, id] = { + "history-scroll": ["history", "scrollrestoration"], + // overloads + "document-open" : ["document", "open"], + "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], + "messageport-postmessage": ["messageport", "postmessage"], + "window-postmessage": ["window", "postmessage"], + "worker-postmessage": ["worker", "postmessage"], + "context-2d-settransform": ["context-2d", "settransform"] + }[containerid] || [containerid, id]; + } + + + const exceptions = { + "worker-navigator": "WorkerGlobalScope", + "navigator-canplaytype": "HTMLMediaElement", + "media-getsvgdocument": "HTMLIFrameElement,HTMLEmbedElement,HTMLObjectElement", + "fe-autofocus": "HTMLOrSVGElement" + }; + + let interfaces = []; + const mixins = { + "context-2d": "CanvasRenderingContext2D", + "navigator": "Navigator" + }; + const fullId = containerid + "-" + id; + if (exceptions[fullId]|| fromIdToIdl(containerid)) { + let names = (exceptions[fullId] ? exceptions[fullId] : fromIdToIdl(containerid)).split(","); + interfaces = idlInterfaces.filter(i => names.includes(i.name)); + } + if (Object.keys(mixins).includes(containerid)) { + // some container id are split across several mixins, lets find out which + const candidateInterfaceNames = [mixins[containerid]].concat(idlTree.filter(inc => inc.type === "includes" && inc.target === mixins[containerid]).map(inc => inc.includes)); + interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); + } + + if (interfaces.length) { + let type = "attribute"; + let relevantInterfaces = interfaces; + if (id) { + type = "dfn"; + // dom-head-profile, intentionally omitted from IDL fragment + if (id === "profile" && containerid === "head") { + return {type: "attribute", _for:"HTMLHeadElement"}; + } + relevantInterfaces = interfaces.filter(iface => iface.members.find(member => member.name && member.name.toLowerCase() === id)); + if (relevantInterfaces.length) { + let idlTerm = relevantInterfaces[0].members.find(member => member.name && member.name.toLowerCase() === id); + type = idlTerm.type === "operation" ? "method" : idlTerm.type; + } + } + return {type, _for: [... new Set(relevantInterfaces.map(iface => iface.name))].join(",")}; + } + + const enumName = id => { switch(id) { + case "context-2d-direction": return "CanvasDirection"; + case "context-2d-fillrule": return "CanvasFillRule"; + case "context-2d-imagesmoothingquality": return "ImageSmoothingQuality"; + case "context-2d-textalign": return "CanvasTextAlign"; + case "context-2d-textbaseline": return "CanvasTextBaseline"; + } + }; + let _enum = idlTree.find(i => i.type === "enum" && (i.name.toLowerCase() === containerid || enumName(containerid) === i.name)); + // TODO check the value is defined + if (_enum) return {type: "enum-value", _for: _enum.name}; + let dict = idlTree.find(i => i.type === "dictionary" && i.name.toLowerCase() === containerid ); + // TODO check the field is defined + if (dict) return {type: "dict-member", _for: dict.name}; + + // Miscellanous exceptions + // Ideally, get this fixed upstream + switch(containerid) { + // not an enum, but a well-defined DOMString + case "datatransfer-dropeffect": return {type: "dfn", _for:"DataTransfer.dropEffect"}; + // not an enum, but a well-defined DOMString + case "datatransfer-effectallowed": return {type: "dfn", _for:"DataTransfer.effectAllowed"}; + case "document-nameditem": return {type: "dfn", _for:"Document"}; + // mode of the value attribute of the inputelement + case "input-value": + case "input-value-default": + return {type: "dfn", _for:"HTMLInputElement.value"}; + // not an enum, but a well-defined DOMString + case "texttrack-kind": return {type: "dfn", _for:"TextTrack.kind"}; + // dom-tree-accessors + case "tree": return { type:"dfn", _for:""}; + case "window-nameditem": return {type: "dfn", _for:"Window"}; + } + + //throw "Cannot match " + containerid + " to a known IDL name (" + id + ")"; + return {type: "unknown", _for: containerid + " with " + id}; + } + + const headingSelector = [ + 'h2[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h3[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h4[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h5[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h6[id$="-element"]:not([data-dfn-type]) dfn:not([id])' + ].join(','); + + // we copy the id on the dfn when it is set on the surrounding heading + [...document.querySelectorAll(headingSelector)] + .forEach(el => { + el.id = el.closest("h2, h3, h4, h5, h6").id; + if (el.id.match(/^the-([^-]*)-element$/)) { + el.dataset.dfnType = 'element'; + } + }); + + const manualIgnore = ["dom-xsltprocessor-transformtofragment", "dom-xsltprocessor-transformtodocument"]; + + // all the definitions in indices.html are non-normative, so we skip them + // to avoid having to properly type them + // they're not all that interesting + [...document.querySelectorAll('section[data-reffy-page$="indices.html"] dfn[id]')].forEach(el => { + el.dataset.dfnSkip = true; + }); + + [...document.querySelectorAll("dfn[id]:not([data-dfn-type]):not([data-skip])")] + .forEach(el => { + // Hard coded rules for special ids + // hyphen in attribute name throws off other match rules + if (el.id === "attr-form-accept-charset") { + el.dataset.dfnType = 'element-attr'; + el.dataset.dfnFor = "form"; + return; + } + // dom-style is defined elsewhere + if (el.id === "dom-style") { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = 'HTMLElement'; + el.dataset.noexport = ""; + return; + } + // audio/menu in a heading with an id, throws off the "heading" convention + if (el.id === "audio" || el.id === "menus") { + el.dataset.dfnType = 'element'; + return; + } + + // If there is a link, we assume this documents an imported definition + // so we make it ignored by removing the id + if (el.querySelector('a[href^="http"]') + || manualIgnore.includes(el.id) + ) { + return; + } + let m; + + if (el.closest("code.idl")) { + // we look if that matches a top-level idl name + let idlTerm = idlTree.find(item => item.name === el.textContent); + if (idlTerm) { + // we split at space to cater for "interface mixin" + el.dataset.dfnType = idlTerm.type.split(' ')[0]; + return; + } + } + if ((m = el.id.match(/^attr-([^-]+)-([^-]+)$/))) { + // e.g. attr-ul-type + el.dataset.dfnType = 'element-attr'; + let _for = fromIdToElement(m[1]); + // special casing usemap attribute + if (m[1] === "hyperlink" && m[2] === "usemap") { + _for = "img,object"; + return; + } + if (m[1] === "aria") { + // reference to external defined elements, noexport + el.dataset.noexport = true; + return; + } + // "loading", "crossorigin", "autocapitalize" are used in middle position + // when describing possible keywords + if (["loading", "crossorigin", "autocapitalize"].includes(m[1])) { + el.dataset.dfnType = 'dfn'; + // Not sure how to indicate this is for an attribute value + // _for = m[1]; + } + if (_for && !el.dataset.dfnFor) { + el.dataset.dfnFor = _for; + } + return; + } + if ((m = el.id.match(/^attr-([^-]+)$/))) { + el.dataset.dfnType = 'element-attr'; + // not sure how to encode "every html element"? + // el.dataset.dfnFor = 'all HTML elements'; + return; + } + if ((m = el.id.match(/^handler-([^-]+)$/))) { + const sharedEventHandlers = ["GlobalEventHandlers", "WindowEventHandlers", "DocumentAndElementEventHandlers"]; + el.dataset.dfnType = 'attribute'; + if (!el.dataset.dfnFor) { + let _for = sharedEventHandlers.filter(iface => idlInterfaces.find(item => item.name === iface && item.members.find(member => member.name === m[1])))[0]; + if (_for) { + el.dataset.dfnFor = _for; + } + } + return; + } + + if ((m = el.id.match(/^handler-([^-]+)-/))) { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = el.dataset.dfnFor || fromIdToTypeAndFor(m[1])._for; + return; + } + + if ((m = el.id.match(/^selector-/))) { + el.dataset.dfnType = 'selector'; + return; + } + + if ((m = el.id.match(/^dom-([^-]+)$/) || el.id.match(/^dom-([^-]+)-[0-9]+$/) || el.id.match(/^dom-([^-]+)-constructor$/))) { + const globalscopes = ["Window", "WindowOrWorkerGlobalScope", "HTMLElement", "WindowSessionStorage", "WorkerGlobalScope", "WindowLocalStorage", "HTMLOrSVGElement", "ElementContentEditable"] + const name = el.textContent.split('(')[0]; + if (el.textContent.match(/\(/)) { + // e.g. print(), Audio(src) + // starts with a capital letter => constructor + if (name.match(/^[A-Z]/)) { + let iface = idlTree.find(item => item.type === "interface" && + // regular constructor + (item.name === name && item.members.find(member => member.type === "constructor") + // LegacyFactoryFunction e.g. Audio() + || item.extAttrs.find(ea => ea.name === "LegacyFactoryFunction" && ea.rhs.value === name))); + if (iface) { + el.dataset.dfnType = 'constructor'; + el.dataset.dfnFor = iface.name; + return; + } + } else { + // otherwise, a method of a global scope + let opContainer = globalscopes.find(scope => idlTree.find(item => item.type.startsWith("interface") && item.name === scope && item.members.find(member => member.type === "operation" && member.name === name))); + if (opContainer) { + el.dataset.dfnType = 'method'; + el.dataset.dfnFor = opContainer; + return; + } + } + } else { + // starts with a capital letter => interface + if (name.match(/^[A-Z]/)) { + let iface = idlTree.find(item => item.type === "interface" && item.name === name); + if (iface) { + el.dataset.dfnType = 'interface'; + return; + } + } else { + // an attribute of a global scope + let attrContainer = globalscopes.find(scope => idlTree.find(item => item.type.startsWith("interface") && item.name === scope && item.members.find(member => member.type === "attribute" && member.name === name))); + if (attrContainer) { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = attrContainer; + return; + } + } + } + return; + } + + if ((m = el.id.match(/^dom-(.+)-([^-]+)$/))) { + const {type, _for} = fromIdToTypeAndFor(m[1], m[2]) + // Special casing all-caps constants + if (m[2].match(/^[A-Z_]+$/)) type = "const"; + el.dataset.dfnType = type; + el.dataset.dfnFor = el.dataset.dfnFor || _for; + return; + } + + if (m = el.id.match(/^event-([a-z]+)$/)) { + if (!el.textContent.match(/ /)) { + el.dataset.dfnType = 'event'; + return; + } + } + + if (m = el.id.match(/^event-([a-z]+)-(.*)$/)) { + if (!el.textContent.match(/ /)) { + if (m[1] === "media" && ["change", "addtrack", "removetrack"].includes(m[2])) { + el.dataset.dfnFor = "AudioTrackList,VideoTrackList,TextTrackList"; + } else { + el.dataset.dfnFor = fromIdToIdl(m[1]) || m[1]; + } + el.dataset.dfnType = 'event'; + return; + } + } + + }); +} diff --git a/src/lib/util.js b/src/lib/util.js index c1ada916..5399b56d 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -297,6 +297,11 @@ async function processSpecification(spec, callback, args, counter) { path: path.resolve(__dirname, '../../builds/browser.js') }); + // Import WebIDL2, needed to parse definitions out of the HTML spec + await page.addScriptTag({ + path: path.resolve(__dirname, '../../node_modules/webidl2/dist/webidl2.js') + }); + // Run the callback method in the browser context const results = await page.evaluate(callback, ...args); diff --git a/tests/extract-dfns.js b/tests/extract-dfns.js index 4911beef..446fa461 100644 --- a/tests/extract-dfns.js +++ b/tests/extract-dfns.js @@ -2,9 +2,43 @@ const { assert } = require('chai'); const puppeteer = require('puppeteer'); const path = require('path'); +// Associating HTML definitions with the right data relies on IDL defined in that spec +const baseHtml = `

+interface ApplicationCache{};
+interface AudioTrackList {};
+interface VideoTrackList {};
+interface TextTrackList {};
+interface mixin DocumentAndElementEventHandlers {
+  attribute EventHandler oncopy;
+};
+interface BroadcastChannel {
+  constructor(DOMString name);
+};
+[LegacyFactoryFunction=Audio(optional DOMString src)]
+interface HTMLAudioElement {
+};
+interface mixin WindowOrWorkerGlobalScope {
+  DOMString btoa(DOMString data);
+};
+interface Window {
+   readonly attribute Navigator navigator;
+};
+interface CustomElementRegistry {
+  Promise<void> whenDefined(DOMString name);
+};
+interface Navigator {
+};
+Navigator includes NavigatorID;
+interface mixin NavigatorID {
+};
+partial interface mixin NavigatorID {
+  [Exposed=Window] boolean taintEnabled();
+};
+enum CanPlayTypeResult { ""};
+
`; + const baseDfn = { id: 'foo', - href: 'about:blank#foo', linkingText: [ 'Foo' ], localLinkingText: [], type: 'dfn', @@ -62,21 +96,253 @@ const tests = [ html: "Foo", changesToBaseDfn: [{linkingText: ["foo", "bar"]}] }, + {title: "marks as public a ", + html: "Foo", + changesToBaseDfn: [{access: 'public'}] + }, + {title: "marks as public a ", + html: "Foo", + changesToBaseDfn: [{access: 'public', type: 'interface'}] + }, + {title: "marks as private a ", + html: "Foo", + changesToBaseDfn: [{type: 'interface'}] + }, + {title: "detects informative definitions", + html: "
Foo
", + changesToBaseDfn: [{informative: true}] + }, + {title: "associates a definition to a namespace", + html: "Foo", + changesToBaseDfn: [{for:['Bar', 'Baz']}] + }, + {title: "considers definitions in headings", + html: "

Foo

", + changesToBaseDfn: [{}] + }, + {title: "ignores elements that aren't and headings", + html: "Foo", + changesToBaseDfn: [] + }, + {title: "ignores headings without a data-dfn-type", + html: "

Foo

", + changesToBaseDfn: [] + }, + {title: "includes data-lt in its list of linking text", + html: "Foo", + changesToBaseDfn: [{linkingText: ["foo", "bar"]}] + }, + {title: "handles HTML spec convention for defining elements", + html: '

4.1.1 The html element

', + changesToBaseDfn: [{id: "the-html-element", + access: "public", + type: "element", + linkingText: ["html"]}], + spec: "html" + }, + {title: "handles HTML spec convention for defining element interfaces", + html: '
interface HTMLHRElement {};
', + changesToBaseDfn: [{id: "htmlhrelement", + access: "public", + type: "interface", + linkingText: ["HTMLHRElement"]}], + spec: "html" + }, + {title: "handles finding IDL type across mixins and partial", + html: 'taintEnabled()', + changesToBaseDfn: [{id: "dom-navigator-taintenabled", + type: "method", + access: "public", + for: ["NavigatorID"], + linkingText: ["taintEnabled()"]}], + spec: "html" + }, + {title: "handles HTML spec convention for CSS selectors", + html: '
:visited
', + changesToBaseDfn: [{id: "selector-visited", + type: "selector", + linkingText: [":visited"]}], + spec: "html" + }, + { + title: "detects HTML spec constructors", + html: 'BroadcastChannel()', + changesToBaseDfn: [{id: "dom-broadcastchannel", + access: "public", + type: "constructor", + linkingText: ["BroadcastChannel()"], + for: ['BroadcastChannel']}], + + spec: "html" + }, + { + title: "detects HTML legacy factory functions", + html: 'Audio(src)', + changesToBaseDfn: [{id: "dom-audio", + access: "public", + type: "constructor", + linkingText: ["Audio(src)"], + for: ['HTMLAudioElement']} + ], + spec: "html" + }, + { + title: "detects methods in the global scope", + html: 'btoa(data)', + changesToBaseDfn: [{id: "dom-btoa", + access: "public", + type: "method", + linkingText: ["btoa(data)"], + for: ['WindowOrWorkerGlobalScope'] + }], + spec: "html" + }, + { + title: "detects attribute in the global scope", + html: 'navigator', + changesToBaseDfn: [{id: "dom-navigator", + access: "public", + type: "attribute", + linkingText: ["navigator"], + for: ['Window'] + }], + spec: "html" + }, + { + title: "handles HTML spec convention for attributes", + html: 'manifest', + changesToBaseDfn: [{id: "attr-html-manifest", + access: "public", + type: "element-attr", + linkingText: ["manifest"], + for: ['html']}], + spec: "html" + }, + { + title: "handles HTML spec convention for methods", + html: 'whenDefined(name)', + changesToBaseDfn: [ + {id:"dom-customelementregistry-whendefined", + access: "public", + type: "method", + linkingText: ["whenDefined(name)"], + for: ["CustomElementRegistry"] + } + ], + spec: "html" + }, + { + title: "handles HTML spec convention for enum values", + html: 'probably', + changesToBaseDfn: [{id: "dom-canplaytyperesult-probably", + access: "public", + type: "enum-value", + linkingText: ["probably"], + for: ['CanPlayTypeResult']}], + spec: "html" + }, + { + title: "handles HTML spec convention for dictionary members", + html: '
dictionary EventSourceInit { boolean withCredentials = false;};
', + changesToBaseDfn: [{id: "dom-eventsourceinit-withcredentials", + access: "public", + type: "dict-member", + linkingText: ["withCredentials"], + for: ['EventSourceInit']}], + spec: "html" + }, + { + title: "handles HTML spec rules for “global” event handlers", + html: 'oncopy ', + changesToBaseDfn: [ + {id: "handler-oncopy", + access: "public", + type: "attribute", + linkingText: ["oncopy"], + for: ['DocumentAndElementEventHandlers']} + ], + spec:"html" + }, + { + title: "handles HTML spec convention for interface-bound event handlers", + html: 'onchange ', + changesToBaseDfn: [{id: "handler-texttracklist-onchange", + access: "public", + type: "attribute", + linkingText: ["onchange"], + for: ['TextTrackList']}], + spec: "html" + }, + { + title: "handles exceptions to HTML spec convention for event handlers", + html: 'onchange ', + changesToBaseDfn: [{id: "handler-tracklist-onchange", + access: "public", + type: "attribute", + linkingText: ["onchange"], + for: ['AudioTrackList', 'VideoTrackList']}], + spec: "html" + }, + { + title: "handles exceptions to HTML spec convention for event handlers", + html: 'onchecking ', + changesToBaseDfn: [{id: "handler-appcache-onchecking", + access: "public", + type: "attribute", + linkingText: ["onchecking"], + for: ['ApplicationCache']}], + spec: "html" + }, + { + title: "doesn't mess up when HTML follows regular conventions", + html: 'onmouseup', + changesToBaseDfn: [{id: "handler-onmouseup", + access: "public", + type: "attribute", + linkingText: ["onmouseup"], + for: ['HTMLElement','Document','Window','GlobalEventHandlers']}], + spec: "html" + }, + { + "title": "ignores defintions imported in the HTML spec from other specs", + html: '
  • The XMLHttpRequest interface
  • ', + changesToBaseDfn: [{ + id: "xmlhttprequest", + linkingText: ["XMLHttpRequest"] + }], + spec: "html" + }, + { + "title": "ignores defintions imported in the indices.html page of the HTML spec", + html: '
    text/xml
    ', + changesToBaseDfn: [{ + id: "text/xml", + linkingText: ["text/xml"], + href: "https://example.org/indices.html#text/xml" + }], + spec: "html" + } + ]; -async function assertExtractedDefinition(browser, html, dfns) { +async function assertExtractedDefinition(browser, html, dfns, spec) { const page = await browser.newPage(); - page.setContent(html); + page.setContent((spec === "html" ? baseHtml : "") + html + ""); await page.addScriptTag({ path: path.resolve(__dirname, '../builds/browser.js') }); + if (spec === "html") { // we need to parse WebIDL to match names in the HTML Spec + await page.addScriptTag({ + path: path.resolve(__dirname, '../node_modules/webidl2/dist/webidl2.js') + }); + } const extractedDfns = await page.evaluate(async () => { - return reffy.extractDefinitions(); + return reffy.extractDefinitions(spec); }); - await page.close(); + await page.close(); - assert.deepEqual(dfns.map(d => Object.assign({}, baseDfn, d)), extractedDfns); + assert.deepEqual(dfns.map(d => Object.assign({}, baseDfn, {href: "about:blank#" + (d.id || baseDfn.id)}, d)), extractedDfns); } @@ -87,7 +353,7 @@ describe("Test definition extraction", () => { }); tests.forEach(t => { - it(t.title, async () => assertExtractedDefinition(browser, t.html, t.changesToBaseDfn)); + it(t.title, async () => assertExtractedDefinition(browser, t.html, t.changesToBaseDfn, t.spec)); }); From b7a69e7216c4501864a2231c1d878c93306a4e39 Mon Sep 17 00:00:00 2001 From: Dominique Hazael-Massieux Date: Mon, 29 Jun 2020 17:04:15 +0200 Subject: [PATCH 2/5] Update build of browser.js --- builds/browser.js | 500 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 438 insertions(+), 62 deletions(-) diff --git a/builds/browser.js b/builds/browser.js index b05afb1d..4bf1b28a 100644 --- a/builds/browser.js +++ b/builds/browser.js @@ -453,74 +453,450 @@ * @public * @return {Array(Object)} An Array of definitions */ - function extractDefinitions () { - const definitionsSelector = [ - 'dfn[id]', - 'h2[id][data-dfn-type]', - 'h3[id][data-dfn-type]', - 'h4[id][data-dfn-type]', - 'h5[id][data-dfn-type]', - 'h6[id][data-dfn-type]' - ].join(','); + function definitionMapper(el) { function normalize(str) { return str.trim().replace(/\s+/g, ' '); } + return { + // ID is the id attribute + id: el.getAttribute('id'), + + // Compute the absolute URL + // (Note the crawler merges pages of a multi-page spec in the first page + // to ease parsing logic, and we want to get back to the URL of the page) + href: (_ => { + const pageWrapper = el.closest('[data-reffy-page]'); + const url = new URL(pageWrapper ? + pageWrapper.getAttribute('data-reffy-page') : window.location.href); + url.hash = '#' + el.getAttribute('id'); + return url.toString(); + })(), + + // Linking text is given by the data-lt attribute if present, or it is the + // textual content + linkingText: el.hasAttribute('data-lt') ? + el.getAttribute('data-lt').split('|').map(normalize) : + [normalize(el.textContent)], + + // Additional linking text can be defined for local references + localLinkingText: el.getAttribute('data-local-lt') ? + el.getAttribute('data-local-lt').split('|').map(normalize) : + [], + + // Link type must be specified, or it is "dfn" + type: el.getAttribute('data-dfn-type') || 'dfn', + + // Definition may be namespaced to other constructs. Note the list is not + // purely comma-separated due to function parameters. For instance, + // attribute value may be "method(foo,bar), method()" + for: el.getAttribute('data-dfn-for') ? + el.getAttribute('data-dfn-for').split(/,(?![^\(]*\))/).map(normalize) : + [], + + // Definition is public if explictly marked as exportable or if export has + // not been explicitly disallowed and its type is not "dfn" + access: (el.hasAttribute('data-export') || + (!el.hasAttribute('data-noexport') && + el.hasAttribute('data-dfn-type') && + el.getAttribute('data-dfn-type') !== 'dfn')) ? + 'public' : 'private', + + // Whether the term is defined in a normative/informative section, + // provided the wrapping section follows usual patterns: + // https://github.com/w3c/respec/blob/develop/src/core/utils.js#L69 + // https://tabatkins.github.io/bikeshed/#metadata-informative-classes + informative: !!el.closest([ + '.informative', '.note', '.issue', '.example', '.ednote', '.practice', + '.introductory', '.non-normative' + + ].join(',')) + }; + } + + function extractDefinitions (spec) { + const definitionsSelector = [ + // re data-lt, see https://github.com/tidoust/reffy/issues/336#issuecomment-650339747 + 'dfn[id]:not([data-lt=""])', + 'h2[id][data-dfn-type]:not([data-lt=""])', + 'h3[id][data-dfn-type]:not([data-lt=""])', + 'h4[id][data-dfn-type]:not([data-lt=""])', + 'h5[id][data-dfn-type]:not([data-lt=""])', + 'h6[id][data-dfn-type]:not([data-lt=""])' + ].join(','); + + if (spec === "html") { + preProcessHTML(); + } + return [...document.querySelectorAll(definitionsSelector)] - .map(el => Object.assign({ - // ID is the id attribute - id: el.getAttribute('id'), - - // Compute the absolute URL - // (Note the crawler merges pages of a multi-page spec in the first page - // to ease parsing logic, and we want to get back to the URL of the page) - href: (_ => { - const pageWrapper = el.closest('[data-reffy-page]'); - const url = new URL(pageWrapper ? - pageWrapper.getAttribute('data-reffy-page') : window.location.href); - url.hash = '#' + el.getAttribute('id'); - return url.toString(); - })(), - - // Linking text is given by the data-lt attribute if present, or it is the - // textual content - linkingText: el.hasAttribute('data-lt') ? - el.getAttribute('data-lt').split('|').map(normalize) : - [normalize(el.textContent)], - - // Additional linking text can be defined for local references - localLinkingText: el.getAttribute('data-local-lt') ? - el.getAttribute('data-local-lt').split('|').map(normalize) : - [], - - // Link type must be specified, or it is "dfn" - type: el.getAttribute('data-dfn-type') || 'dfn', - - // Definition may be namespaced to other constructs. Note the list is not - // purely comma-separated due to function parameters. For instance, - // attribute value may be "method(foo,bar), method()" - for: el.getAttribute('data-dfn-for') ? - el.getAttribute('data-dfn-for').split(/,(?![^\(]*\))/).map(normalize) : - [], - - // Definition is public if explictly marked as exportable or if export has - // not been explicitly disallowed and its type is not "dfn" - access: (el.hasAttribute('data-export') || - (!el.hasAttribute('data-noexport') && - el.hasAttribute('data-dfn-type') && - el.getAttribute('data-dfn-type') !== 'dfn')) ? - 'public' : 'private', - - // Whether the term is defined in a normative/informative section, - // provided the wrapping section follows usual patterns: - // https://github.com/w3c/respec/blob/develop/src/core/utils.js#L69 - // https://tabatkins.github.io/bikeshed/#metadata-informative-classes - informative: !!el.closest([ - '.informative', '.note', '.issue', '.example', '.ednote', '.practice', - '.introductory', '.non-normative' - ].join(',')) - })); + .map(definitionMapper); + } + + function preProcessHTML() { + // We need to extract the list of possible interfaces by parsing the WebIDL of the spec first + const idl = window.reffy.extractWebIdl(); + const idlTree = WebIDL2.parse(idl); + const idlInterfaces = idlTree.filter(item => item.type === "interface" || item.type === "interface mixin"); + + function fromIdToElement(id) { + switch(id) { + case "hyperlink": return "a,area"; + case "mod": return "ins,del"; + case "dim": return "img,iframe,embed,object,video"; + // The spec lists img, but img doesn't have a form attribute + case "fae": return "button,fieldset,input,object,output,select,textarea"; + case "fe": return "button,fieldset,input,object,output,select,textarea"; + case "fs": return "form,button"; + case "hx": return "h1,h2,h3,h4,h5,h6"; + case "tdth": return "td,th"; + // xml: attributes are id'd as xml- + // case "xml": return "all HTML elements"; + case "xml": return undefined; + + } return id; + } + + function fromIdToIdl(id) { + const specialInterfaceIds = { + "appcache": "ApplicationCache", + "a": "HTMLAnchorElement", + "caption": "HTMLTableCaptionElement", + "colgroup": "HTMLTableColElement", + "col": "HTMLTableColElement", + "context-2d-canvas": "CanvasRenderingContext2D", + // submittable elements https://html.spec.whatwg.org/multipage/forms.html#category-submit + "cva": "HTMLButtonElement,HTMLInputElement,HTMLObjectElement,HTMLSelectElement,HTMLTextAreaElement", + "dnd": "GlobalEventHandlers", + "dim": "HTMLImageElement,HTMLIFrameElement,HTMLEmbedElement,HTMLObjectElement,HTMLVideoElement", + "dir": "HTMLDirectoryElement", + "dl": "HTMLDListElement", + // form associated elements https://html.spec.whatwg.org/multipage/forms.html#form-associated-element + // The spec lists img, but img doesn't have a form attribute + "fae": "HTMLButtonElement,HTMLFieldsetElement,HTMLInputElement,HTMLObjectElement,HTMLOutputElement,HTMLSelectElement,HTMLTextAreaElement", + // form elements https://html.spec.whatwg.org/multipage/forms.html#category-listed + "fe": "HTMLButtonElement,HTMLFieldsetElement,HTMLInputElement,HTMLSelectElement,HTMLTextAreaElement", + // Form submission attributes https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-attributes + // some are for button some for form + "fs": "HTMLButtonElement,HTMLFormElement", + "hx": "HTMLHeadingElement", + "hyperlink": "HTMLHyperlinkElementUtils", + "img": "HTMLImageElement", + // Labelable form elements + "lfe": "HTMLButtonElement,HTMLInputElement,HTMLMeterElement,HTMLOutputElement,HTMLProgressElement,HTMLSelectElement,HTMLTextAreaElement", + "ol": "HTMLOListElement", + "p": "HTMLParagraphElement", + "tbody": "HTMLTableSectionElement", + "td": "HTMLTableCellElement", + "th": "HTMLTableCellElement", + "tdth": "HTMLTableCellElement", + "textarea/input": "HTMLTextAreaElement,HTMLInputElement", + "tr": "HTMLTableRowElement", + "tracklist": "AudioTrackList,VideoTrackList", + "ul": "HTMLUListElement" + }; + if (specialInterfaceIds[id]) { + return specialInterfaceIds[id]; + } + let iface = idlInterfaces.find(i => i.name.toLowerCase() === id || i.name.toLowerCase() === `html${id}element`); + if (iface) { + return iface.name; + } + } + + function fromIdToTypeAndFor(containerid, id) { + // deals with exceptions to how containerid / id are expected to be parsed + if (id) { + [containerid, id] = { + "history-scroll": ["history", "scrollrestoration"], + // overloads + "document-open" : ["document", "open"], + "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], + "messageport-postmessage": ["messageport", "postmessage"], + "window-postmessage": ["window", "postmessage"], + "worker-postmessage": ["worker", "postmessage"], + "context-2d-settransform": ["context-2d", "settransform"] + }[containerid] || [containerid, id]; + } + + + const exceptions = { + "worker-navigator": "WorkerGlobalScope", + "navigator-canplaytype": "HTMLMediaElement", + "media-getsvgdocument": "HTMLIFrameElement,HTMLEmbedElement,HTMLObjectElement", + "fe-autofocus": "HTMLOrSVGElement" + }; + + let interfaces = []; + const mixins = { + "context-2d": "CanvasRenderingContext2D", + "navigator": "Navigator" + }; + const fullId = containerid + "-" + id; + if (exceptions[fullId]|| fromIdToIdl(containerid)) { + let names = (exceptions[fullId] ? exceptions[fullId] : fromIdToIdl(containerid)).split(","); + interfaces = idlInterfaces.filter(i => names.includes(i.name)); + } + if (Object.keys(mixins).includes(containerid)) { + // some container id are split across several mixins, lets find out which + const candidateInterfaceNames = [mixins[containerid]].concat(idlTree.filter(inc => inc.type === "includes" && inc.target === mixins[containerid]).map(inc => inc.includes)); + interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); + } + + if (interfaces.length) { + let type = "attribute"; + let relevantInterfaces = interfaces; + if (id) { + type = "dfn"; + // dom-head-profile, intentionally omitted from IDL fragment + if (id === "profile" && containerid === "head") { + return {type: "attribute", _for:"HTMLHeadElement"}; + } + relevantInterfaces = interfaces.filter(iface => iface.members.find(member => member.name && member.name.toLowerCase() === id)); + if (relevantInterfaces.length) { + let idlTerm = relevantInterfaces[0].members.find(member => member.name && member.name.toLowerCase() === id); + type = idlTerm.type === "operation" ? "method" : idlTerm.type; + } + } + return {type, _for: [... new Set(relevantInterfaces.map(iface => iface.name))].join(",")}; + } + + const enumName = id => { switch(id) { + case "context-2d-direction": return "CanvasDirection"; + case "context-2d-fillrule": return "CanvasFillRule"; + case "context-2d-imagesmoothingquality": return "ImageSmoothingQuality"; + case "context-2d-textalign": return "CanvasTextAlign"; + case "context-2d-textbaseline": return "CanvasTextBaseline"; + } + }; + let _enum = idlTree.find(i => i.type === "enum" && (i.name.toLowerCase() === containerid || enumName(containerid) === i.name)); + // TODO check the value is defined + if (_enum) return {type: "enum-value", _for: _enum.name}; + let dict = idlTree.find(i => i.type === "dictionary" && i.name.toLowerCase() === containerid ); + // TODO check the field is defined + if (dict) return {type: "dict-member", _for: dict.name}; + + // Miscellanous exceptions + // Ideally, get this fixed upstream + switch(containerid) { + // not an enum, but a well-defined DOMString + case "datatransfer-dropeffect": return {type: "dfn", _for:"DataTransfer.dropEffect"}; + // not an enum, but a well-defined DOMString + case "datatransfer-effectallowed": return {type: "dfn", _for:"DataTransfer.effectAllowed"}; + case "document-nameditem": return {type: "dfn", _for:"Document"}; + // mode of the value attribute of the inputelement + case "input-value": + case "input-value-default": + return {type: "dfn", _for:"HTMLInputElement.value"}; + // not an enum, but a well-defined DOMString + case "texttrack-kind": return {type: "dfn", _for:"TextTrack.kind"}; + // dom-tree-accessors + case "tree": return { type:"dfn", _for:""}; + case "window-nameditem": return {type: "dfn", _for:"Window"}; + } + + //throw "Cannot match " + containerid + " to a known IDL name (" + id + ")"; + return {type: "unknown", _for: containerid + " with " + id}; + } + + const headingSelector = [ + 'h2[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h3[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h4[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h5[id$="-element"]:not([data-dfn-type]) dfn:not([id])', + 'h6[id$="-element"]:not([data-dfn-type]) dfn:not([id])' + ].join(','); + + // we copy the id on the dfn when it is set on the surrounding heading + [...document.querySelectorAll(headingSelector)] + .forEach(el => { + el.id = el.closest("h2, h3, h4, h5, h6").id; + if (el.id.match(/^the-([^-]*)-element$/)) { + el.dataset.dfnType = 'element'; + } + }); + + const manualIgnore = ["dom-xsltprocessor-transformtofragment", "dom-xsltprocessor-transformtodocument"]; + + // all the definitions in indices.html are non-normative, so we skip them + // to avoid having to properly type them + // they're not all that interesting + [...document.querySelectorAll('section[data-reffy-page$="indices.html"] dfn[id]')].forEach(el => { + el.dataset.dfnSkip = true; + }); + + [...document.querySelectorAll("dfn[id]:not([data-dfn-type]):not([data-skip])")] + .forEach(el => { + // Hard coded rules for special ids + // hyphen in attribute name throws off other match rules + if (el.id === "attr-form-accept-charset") { + el.dataset.dfnType = 'element-attr'; + el.dataset.dfnFor = "form"; + return; + } + // dom-style is defined elsewhere + if (el.id === "dom-style") { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = 'HTMLElement'; + el.dataset.noexport = ""; + return; + } + // audio/menu in a heading with an id, throws off the "heading" convention + if (el.id === "audio" || el.id === "menus") { + el.dataset.dfnType = 'element'; + return; + } + + // If there is a link, we assume this documents an imported definition + // so we make it ignored by removing the id + if (el.querySelector('a[href^="http"]') + || manualIgnore.includes(el.id) + ) { + return; + } + let m; + + if (el.closest("code.idl")) { + // we look if that matches a top-level idl name + let idlTerm = idlTree.find(item => item.name === el.textContent); + if (idlTerm) { + // we split at space to cater for "interface mixin" + el.dataset.dfnType = idlTerm.type.split(' ')[0]; + return; + } + } + if ((m = el.id.match(/^attr-([^-]+)-([^-]+)$/))) { + // e.g. attr-ul-type + el.dataset.dfnType = 'element-attr'; + let _for = fromIdToElement(m[1]); + // special casing usemap attribute + if (m[1] === "hyperlink" && m[2] === "usemap") { + _for = "img,object"; + return; + } + if (m[1] === "aria") { + // reference to external defined elements, noexport + el.dataset.noexport = true; + return; + } + // "loading", "crossorigin", "autocapitalize" are used in middle position + // when describing possible keywords + if (["loading", "crossorigin", "autocapitalize"].includes(m[1])) { + el.dataset.dfnType = 'dfn'; + // Not sure how to indicate this is for an attribute value + // _for = m[1]; + } + if (_for && !el.dataset.dfnFor) { + el.dataset.dfnFor = _for; + } + return; + } + if ((m = el.id.match(/^attr-([^-]+)$/))) { + el.dataset.dfnType = 'element-attr'; + // not sure how to encode "every html element"? + // el.dataset.dfnFor = 'all HTML elements'; + return; + } + if ((m = el.id.match(/^handler-([^-]+)$/))) { + const sharedEventHandlers = ["GlobalEventHandlers", "WindowEventHandlers", "DocumentAndElementEventHandlers"]; + el.dataset.dfnType = 'attribute'; + if (!el.dataset.dfnFor) { + let _for = sharedEventHandlers.filter(iface => idlInterfaces.find(item => item.name === iface && item.members.find(member => member.name === m[1])))[0]; + if (_for) { + el.dataset.dfnFor = _for; + } + } + return; + } + + if ((m = el.id.match(/^handler-([^-]+)-/))) { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = el.dataset.dfnFor || fromIdToTypeAndFor(m[1])._for; + return; + } + + if ((m = el.id.match(/^selector-/))) { + el.dataset.dfnType = 'selector'; + return; + } + + if ((m = el.id.match(/^dom-([^-]+)$/) || el.id.match(/^dom-([^-]+)-[0-9]+$/) || el.id.match(/^dom-([^-]+)-constructor$/))) { + const globalscopes = ["Window", "WindowOrWorkerGlobalScope", "HTMLElement", "WindowSessionStorage", "WorkerGlobalScope", "WindowLocalStorage", "HTMLOrSVGElement", "ElementContentEditable"]; + const name = el.textContent.split('(')[0]; + if (el.textContent.match(/\(/)) { + // e.g. print(), Audio(src) + // starts with a capital letter => constructor + if (name.match(/^[A-Z]/)) { + let iface = idlTree.find(item => item.type === "interface" && + // regular constructor + (item.name === name && item.members.find(member => member.type === "constructor") + // LegacyFactoryFunction e.g. Audio() + || item.extAttrs.find(ea => ea.name === "LegacyFactoryFunction" && ea.rhs.value === name))); + if (iface) { + el.dataset.dfnType = 'constructor'; + el.dataset.dfnFor = iface.name; + return; + } + } else { + // otherwise, a method of a global scope + let opContainer = globalscopes.find(scope => idlTree.find(item => item.type.startsWith("interface") && item.name === scope && item.members.find(member => member.type === "operation" && member.name === name))); + if (opContainer) { + el.dataset.dfnType = 'method'; + el.dataset.dfnFor = opContainer; + return; + } + } + } else { + // starts with a capital letter => interface + if (name.match(/^[A-Z]/)) { + let iface = idlTree.find(item => item.type === "interface" && item.name === name); + if (iface) { + el.dataset.dfnType = 'interface'; + return; + } + } else { + // an attribute of a global scope + let attrContainer = globalscopes.find(scope => idlTree.find(item => item.type.startsWith("interface") && item.name === scope && item.members.find(member => member.type === "attribute" && member.name === name))); + if (attrContainer) { + el.dataset.dfnType = 'attribute'; + el.dataset.dfnFor = attrContainer; + return; + } + } + } + return; + } + + if ((m = el.id.match(/^dom-(.+)-([^-]+)$/))) { + const {type, _for} = fromIdToTypeAndFor(m[1], m[2]); + // Special casing all-caps constants + if (m[2].match(/^[A-Z_]+$/)) type = "const"; + el.dataset.dfnType = type; + el.dataset.dfnFor = el.dataset.dfnFor || _for; + return; + } + + if (m = el.id.match(/^event-([a-z]+)$/)) { + if (!el.textContent.match(/ /)) { + el.dataset.dfnType = 'event'; + return; + } + } + + if (m = el.id.match(/^event-([a-z]+)-(.*)$/)) { + if (!el.textContent.match(/ /)) { + if (m[1] === "media" && ["change", "addtrack", "removetrack"].includes(m[2])) { + el.dataset.dfnFor = "AudioTrackList,VideoTrackList,TextTrackList"; + } else { + el.dataset.dfnFor = fromIdToIdl(m[1]) || m[1]; + } + el.dataset.dfnType = 'event'; + return; + } + } + + }); } /** From 18f2b3a0b8cf67382caf50df77d4122f9917a51c Mon Sep 17 00:00:00 2001 From: Dominique Hazael-Massieux Date: Tue, 30 Jun 2020 09:14:03 +0200 Subject: [PATCH 3/5] Use modules rather than script for libraries import Per https://github.com/tidoust/reffy/pull/340#discussion_r447110601 and https://github.com/tidoust/reffy/pull/340#discussion_r447113529 --- builds/browser.js | 2188 +++++++++++++++++++++++++++++++- src/browserlib/extract-dfns.js | 6 +- tests/extract-dfns.js | 5 - 3 files changed, 2190 insertions(+), 9 deletions(-) diff --git a/builds/browser.js b/builds/browser.js index 4bf1b28a..049b6f94 100644 --- a/builds/browser.js +++ b/builds/browser.js @@ -432,6 +432,2190 @@ return res; }; + /** + * @param {string} text + */ + function lastLine(text) { + const splitted = text.split("\n"); + return splitted[splitted.length - 1]; + } + + /** + * @typedef {object} WebIDL2ErrorOptions + * @property {"error" | "warning"} [level] + * @property {Function} [autofix] + * + * @param {string} message error message + * @param {"Syntax" | "Validation"} kind error type + * @param {WebIDL2ErrorOptions} [options] + */ + function error(source, position, current, message, kind, { level = "error", autofix, ruleName } = {}) { + /** + * @param {number} count + */ + function sliceTokens(count) { + return count > 0 ? + source.slice(position, position + count) : + source.slice(Math.max(position + count, 0), position); + } + + function tokensToText(inputs, { precedes } = {}) { + const text = inputs.map(t => t.trivia + t.value).join(""); + const nextToken = source[position]; + if (nextToken.type === "eof") { + return text; + } + if (precedes) { + return text + nextToken.trivia; + } + return text.slice(nextToken.trivia.length); + } + + const maxTokens = 5; // arbitrary but works well enough + const line = + source[position].type !== "eof" ? source[position].line : + source.length > 1 ? source[position - 1].line : + 1; + + const precedingLastLine = lastLine( + tokensToText(sliceTokens(-maxTokens), { precedes: true }) + ); + + const subsequentTokens = sliceTokens(maxTokens); + const subsequentText = tokensToText(subsequentTokens); + const subsequentFirstLine = subsequentText.split("\n")[0]; + + const spaced = " ".repeat(precedingLastLine.length) + "^"; + const sourceContext = precedingLastLine + subsequentFirstLine + "\n" + spaced; + + const contextType = kind === "Syntax" ? "since" : "inside"; + const inSourceName = source.name ? ` in ${source.name}` : ""; + const grammaticalContext = (current && current.name) ? `, ${contextType} \`${current.partial ? "partial " : ""}${current.type} ${current.name}\`` : ""; + const context = `${kind} error at line ${line}${inSourceName}${grammaticalContext}:\n${sourceContext}`; + return { + message: `${context} ${message}`, + bareMessage: message, + context, + line, + sourceName: source.name, + level, + ruleName, + autofix, + input: subsequentText, + tokens: subsequentTokens + }; + } + + /** + * @param {string} message error message + */ + function syntaxError(source, position, current, message) { + return error(source, position, current, message, "Syntax"); + } + + /** + * @param {string} message error message + * @param {WebIDL2ErrorOptions} [options] + */ + function validationError(token, current, ruleName, message, options = {}) { + options.ruleName = ruleName; + return error(current.source, token.index, current, message, "Validation", options); + } + + // @ts-check + + class Base { + /** + * @param {object} initializer + * @param {Base["source"]} initializer.source + * @param {Base["tokens"]} initializer.tokens + */ + constructor({ source, tokens }) { + Object.defineProperties(this, { + source: { value: source }, + tokens: { value: tokens, writable: true }, + parent: { value: null, writable: true }, + this: { value: this } // useful when escaping from proxy + }); + } + + toJSON() { + const json = { type: undefined, name: undefined, inheritance: undefined }; + let proto = this; + while (proto !== Object.prototype) { + const descMap = Object.getOwnPropertyDescriptors(proto); + for (const [key, value] of Object.entries(descMap)) { + if (value.enumerable || value.get) { + // @ts-ignore - allow indexing here + json[key] = this[key]; + } + } + proto = Object.getPrototypeOf(proto); + } + return json; + } + } + + // @ts-check + + /** + * @typedef {import("../productions/dictionary.js").Dictionary} Dictionary + * + * @param {*} idlType + * @param {import("../validator.js").Definitions} defs + * @param {object} [options] + * @param {boolean} [options.useNullableInner] use when the input idlType is nullable and you want to use its inner type + * @return {{ reference: *, dictionary: Dictionary }} the type reference that ultimately includes dictionary. + */ + function idlTypeIncludesDictionary(idlType, defs, { useNullableInner } = {}) { + if (!idlType.union) { + const def = defs.unique.get(idlType.idlType); + if (!def) { + return; + } + if (def.type === "typedef") { + const { typedefIncludesDictionary } = defs.cache; + if (typedefIncludesDictionary.has(def)) { + // Note that this also halts when it met indeterminate state + // to prevent infinite recursion + return typedefIncludesDictionary.get(def); + } + defs.cache.typedefIncludesDictionary.set(def, undefined); // indeterminate state + const result = idlTypeIncludesDictionary(def.idlType, defs); + defs.cache.typedefIncludesDictionary.set(def, result); + if (result) { + return { + reference: idlType, + dictionary: result.dictionary + }; + } + } + if (def.type === "dictionary" && (useNullableInner || !idlType.nullable)) { + return { + reference: idlType, + dictionary: def + }; + } + } + for (const subtype of idlType.subtype) { + const result = idlTypeIncludesDictionary(subtype, defs); + if (result) { + if (subtype.union) { + return result; + } + return { + reference: subtype, + dictionary: result.dictionary + }; + } + } + } + + /** + * @param {*} dict dictionary type + * @param {import("../validator.js").Definitions} defs + * @return {boolean} + */ + function dictionaryIncludesRequiredField(dict, defs) { + if (defs.cache.dictionaryIncludesRequiredField.has(dict)) { + return defs.cache.dictionaryIncludesRequiredField.get(dict); + } + defs.cache.dictionaryIncludesRequiredField.set(dict, undefined); // indeterminate + if (dict.inheritance) { + const superdict = defs.unique.get(dict.inheritance); + if (!superdict) { + return true; + } + if (dictionaryIncludesRequiredField(superdict, defs)) { + return true; + } + } + const result = dict.members.some(field => field.required); + defs.cache.dictionaryIncludesRequiredField.set(dict, result); + return result; + } + + // @ts-check + + class ArrayBase extends Array { + constructor({ source, tokens }) { + super(); + Object.defineProperties(this, { + source: { value: source }, + tokens: { value: tokens }, + parent: { value: null, writable: true } + }); + } + } + + // @ts-check + + class Token extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} type + */ + static parser(tokeniser, type) { + return () => { + const value = tokeniser.consume(type); + if (value) { + return new Token({ source: tokeniser.source, tokens: { value } }); + } + }; + } + + get value() { + return unescape(this.tokens.value.value); + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} tokenName + */ + function tokens(tokeniser, tokenName) { + return list(tokeniser, { + parser: Token.parser(tokeniser, tokenName), + listName: tokenName + " list" + }); + } + + const extAttrValueSyntax = ["identifier", "decimal", "integer", "string"]; + + const shouldBeLegacyPrefixed = [ + "NoInterfaceObject", + "LenientSetter", + "LenientThis", + "TreatNonObjectAsNull", + "Unforgeable", + ]; + + const renamedLegacies = new Map([ + ...shouldBeLegacyPrefixed.map(name => [name, `Legacy${name}`]), + ["NamedConstructor", "LegacyFactoryFunction"], + ["OverrideBuiltins", "LegacyOverrideBuiltIns"], + ["TreatNullAs", "LegacyNullToEmptyString"], + ]); + + /** + * This will allow a set of extended attribute values to be parsed. + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function extAttrListItems(tokeniser) { + for (const syntax of extAttrValueSyntax) { + const toks = tokens(tokeniser, syntax); + if (toks.length) { + return toks; + } + } + tokeniser.error(`Expected identifiers, strings, decimals, or integers but none found`); + } + + + class ExtendedAttributeParameters extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const tokens = { assign: tokeniser.consume("=") }; + const ret = autoParenter(new ExtendedAttributeParameters({ source: tokeniser.source, tokens })); + if (tokens.assign) { + tokens.secondaryName = tokeniser.consume(...extAttrValueSyntax); + } + tokens.open = tokeniser.consume("("); + if (tokens.open) { + ret.list = ret.rhsIsList ? + // [Exposed=(Window,Worker)] + extAttrListItems(tokeniser) : + // [LegacyFactoryFunction=Audio(DOMString src)] or [Constructor(DOMString str)] + argument_list(tokeniser); + tokens.close = tokeniser.consume(")") || tokeniser.error("Unexpected token in extended attribute argument list"); + } else if (ret.hasRhs && !tokens.secondaryName) { + tokeniser.error("No right hand side to extended attribute assignment"); + } + return ret.this; + } + + get rhsIsList() { + return this.tokens.assign && !this.tokens.secondaryName; + } + + get rhsType() { + if (this.rhsIsList) { + return this.list[0].tokens.value.type + "-list"; + } + if (this.tokens.secondaryName) { + return this.tokens.secondaryName.type; + } + return null; + } + } + + class SimpleExtendedAttribute extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const name = tokeniser.consume("identifier"); + if (name) { + return new SimpleExtendedAttribute({ + source: tokeniser.source, + tokens: { name }, + params: ExtendedAttributeParameters.parse(tokeniser) + }); + } + } + + constructor({ source, tokens, params }) { + super({ source, tokens }); + params.parent = this; + Object.defineProperty(this, "params", { value: params }); + } + + get type() { + return "extended-attribute"; + } + get name() { + return this.tokens.name.value; + } + get rhs() { + const { rhsType: type, tokens, list } = this.params; + if (!type) { + return null; + } + const value = this.params.rhsIsList ? list : unescape(tokens.secondaryName.value); + return { type, value }; + } + get arguments() { + const { rhsIsList, list } = this.params; + if (!list || rhsIsList) { + return []; + } + return list; + } + + *validate(defs) { + const { name } = this; + if (name === "LegacyNoInterfaceObject") { + const message = `\`[LegacyNoInterfaceObject]\` extended attribute is an \ +undesirable feature that may be removed from Web IDL in the future. Refer to the \ +[relevant upstream PR](https://github.com/heycam/webidl/pull/609) for more \ +information.`; + yield validationError(this.tokens.name, this, "no-nointerfaceobject", message, { level: "warning" }); + } else if (renamedLegacies.has(name)) { + const message = `\`[${name}]\` extended attribute is a legacy feature \ +that is now renamed to \`[${renamedLegacies.get(name)}]\`. Refer to the \ +[relevant upstream PR](https://github.com/heycam/webidl/pull/870) for more \ +information.`; + yield validationError(this.tokens.name, this, "renamed-legacy", message, { + level: "warning", + autofix: renameLegacyExtendedAttribute(this) + }); + } + for (const arg of this.arguments) { + yield* arg.validate(defs); + } + } + } + + /** + * @param {SimpleExtendedAttribute} extAttr + */ + function renameLegacyExtendedAttribute(extAttr) { + return () => { + const { name } = extAttr; + extAttr.tokens.name.value = renamedLegacies.get(name); + if (name === "TreatNullAs") { + extAttr.params.tokens = {}; + } + }; + } + + // Note: we parse something simpler than the official syntax. It's all that ever + // seems to be used + class ExtendedAttributes extends ArrayBase { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const tokens = {}; + tokens.open = tokeniser.consume("["); + if (!tokens.open) return new ExtendedAttributes({}); + const ret = new ExtendedAttributes({ source: tokeniser.source, tokens }); + ret.push(...list(tokeniser, { + parser: SimpleExtendedAttribute.parse, + listName: "extended attribute" + })); + tokens.close = tokeniser.consume("]") || tokeniser.error("Unexpected closing token of extended attribute"); + if (!ret.length) { + tokeniser.error("Found an empty extended attribute"); + } + if (tokeniser.probe("[")) { + tokeniser.error("Illegal double extended attribute lists, consider merging them"); + } + return ret; + } + + *validate(defs) { + for (const extAttr of this) { + yield* extAttr.validate(defs); + } + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} typeName + */ + function generic_type(tokeniser, typeName) { + const base = tokeniser.consume("FrozenArray", "Promise", "sequence", "record"); + if (!base) { + return; + } + const ret = autoParenter(new Type({ source: tokeniser.source, tokens: { base } })); + ret.tokens.open = tokeniser.consume("<") || tokeniser.error(`No opening bracket after ${base.type}`); + switch (base.type) { + case "Promise": { + if (tokeniser.probe("[")) tokeniser.error("Promise type cannot have extended attribute"); + const subtype = return_type(tokeniser, typeName) || tokeniser.error("Missing Promise subtype"); + ret.subtype.push(subtype); + break; + } + case "sequence": + case "FrozenArray": { + const subtype = type_with_extended_attributes(tokeniser, typeName) || tokeniser.error(`Missing ${base.type} subtype`); + ret.subtype.push(subtype); + break; + } + case "record": { + if (tokeniser.probe("[")) tokeniser.error("Record key cannot have extended attribute"); + const keyType = tokeniser.consume(...stringTypes) || tokeniser.error(`Record key must be one of: ${stringTypes.join(", ")}`); + const keyIdlType = new Type({ source: tokeniser.source, tokens: { base: keyType }}); + keyIdlType.tokens.separator = tokeniser.consume(",") || tokeniser.error("Missing comma after record key type"); + keyIdlType.type = typeName; + const valueType = type_with_extended_attributes(tokeniser, typeName) || tokeniser.error("Error parsing generic type record"); + ret.subtype.push(keyIdlType, valueType); + break; + } + } + if (!ret.idlType) tokeniser.error(`Error parsing generic type ${base.type}`); + ret.tokens.close = tokeniser.consume(">") || tokeniser.error(`Missing closing bracket after ${base.type}`); + return ret.this; + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function type_suffix(tokeniser, obj) { + const nullable = tokeniser.consume("?"); + if (nullable) { + obj.tokens.nullable = nullable; + } + if (tokeniser.probe("?")) tokeniser.error("Can't nullable more than once"); + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} typeName + */ + function single_type(tokeniser, typeName) { + let ret = generic_type(tokeniser, typeName) || primitive_type(tokeniser); + if (!ret) { + const base = tokeniser.consume("identifier", ...stringTypes, ...typeNameKeywords); + if (!base) { + return; + } + ret = new Type({ source: tokeniser.source, tokens: { base } }); + if (tokeniser.probe("<")) tokeniser.error(`Unsupported generic type ${base.value}`); + } + if (ret.generic === "Promise" && tokeniser.probe("?")) { + tokeniser.error("Promise type cannot be nullable"); + } + ret.type = typeName || null; + type_suffix(tokeniser, ret); + if (ret.nullable && ret.idlType === "any") tokeniser.error("Type `any` cannot be made nullable"); + return ret; + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} type + */ + function union_type(tokeniser, type) { + const tokens = {}; + tokens.open = tokeniser.consume("("); + if (!tokens.open) return; + const ret = autoParenter(new Type({ source: tokeniser.source, tokens })); + ret.type = type || null; + while (true) { + const typ = type_with_extended_attributes(tokeniser) || tokeniser.error("No type after open parenthesis or 'or' in union type"); + if (typ.idlType === "any") tokeniser.error("Type `any` cannot be included in a union type"); + if (typ.generic === "Promise") tokeniser.error("Type `Promise` cannot be included in a union type"); + ret.subtype.push(typ); + const or = tokeniser.consume("or"); + if (or) { + typ.tokens.separator = or; + } + else break; + } + if (ret.idlType.length < 2) { + tokeniser.error("At least two types are expected in a union type but found less"); + } + tokens.close = tokeniser.consume(")") || tokeniser.error("Unterminated union type"); + type_suffix(tokeniser, ret); + return ret.this; + } + + class Type extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} typeName + */ + static parse(tokeniser, typeName) { + return single_type(tokeniser, typeName) || union_type(tokeniser, typeName); + } + + constructor({ source, tokens }) { + super({ source, tokens }); + Object.defineProperty(this, "subtype", { value: [], writable: true }); + this.extAttrs = new ExtendedAttributes({}); + } + + get generic() { + if (this.subtype.length && this.tokens.base) { + return this.tokens.base.value; + } + return ""; + } + get nullable() { + return Boolean(this.tokens.nullable); + } + get union() { + return Boolean(this.subtype.length) && !this.tokens.base; + } + get idlType() { + if (this.subtype.length) { + return this.subtype; + } + // Adding prefixes/postfixes for "unrestricted float", etc. + const name = [ + this.tokens.prefix, + this.tokens.base, + this.tokens.postfix + ].filter(t => t).map(t => t.value).join(" "); + return unescape(name); + } + + *validate(defs) { + yield* this.extAttrs.validate(defs); + /* + * If a union is nullable, its subunions cannot include a dictionary + * If not, subunions may include dictionaries if each union is not nullable + */ + const typedef = !this.union && defs.unique.get(this.idlType); + const target = + this.union ? this : + (typedef && typedef.type === "typedef") ? typedef.idlType : + undefined; + if (target && this.nullable) { + // do not allow any dictionary + const { reference } = idlTypeIncludesDictionary(target, defs) || {}; + if (reference) { + const targetToken = (this.union ? reference : this).tokens.base; + const message = `Nullable union cannot include a dictionary type`; + yield validationError(targetToken, this, "no-nullable-union-dict", message); + } + } else { + // allow some dictionary + for (const subtype of this.subtype) { + yield* subtype.validate(defs); + } + } + } + } + + class Default extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const assign = tokeniser.consume("="); + if (!assign) { + return null; + } + const def = const_value(tokeniser) || tokeniser.consume("string", "null", "[", "{") || tokeniser.error("No value for default"); + const expression = [def]; + if (def.type === "[") { + const close = tokeniser.consume("]") || tokeniser.error("Default sequence value must be empty"); + expression.push(close); + } else if (def.type === "{") { + const close = tokeniser.consume("}") || tokeniser.error("Default dictionary value must be empty"); + expression.push(close); + } + return new Default({ source: tokeniser.source, tokens: { assign }, expression }); + } + + constructor({ source, tokens, expression }) { + super({ source, tokens }); + expression.parent = this; + Object.defineProperty(this, "expression", { value: expression }); + } + + get type() { + return const_data(this.expression[0]).type; + } + get value() { + return const_data(this.expression[0]).value; + } + get negative() { + return const_data(this.expression[0]).negative; + } + } + + // @ts-check + + class Argument extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const start_position = tokeniser.position; + /** @type {Base["tokens"]} */ + const tokens = {}; + const ret = autoParenter(new Argument({ source: tokeniser.source, tokens })); + ret.extAttrs = ExtendedAttributes.parse(tokeniser); + tokens.optional = tokeniser.consume("optional"); + ret.idlType = type_with_extended_attributes(tokeniser, "argument-type"); + if (!ret.idlType) { + return tokeniser.unconsume(start_position); + } + if (!tokens.optional) { + tokens.variadic = tokeniser.consume("..."); + } + tokens.name = tokeniser.consume("identifier", ...argumentNameKeywords); + if (!tokens.name) { + return tokeniser.unconsume(start_position); + } + ret.default = tokens.optional ? Default.parse(tokeniser) : null; + return ret.this; + } + + get type() { + return "argument"; + } + get optional() { + return !!this.tokens.optional; + } + get variadic() { + return !!this.tokens.variadic; + } + get name() { + return unescape(this.tokens.name.value); + } + + /** + * @param {import("../validator.js").Definitions} defs + */ + *validate(defs) { + yield* this.idlType.validate(defs); + const result = idlTypeIncludesDictionary(this.idlType, defs, { useNullableInner: true }); + if (result) { + if (this.idlType.nullable) { + const message = `Dictionary arguments cannot be nullable.`; + yield validationError(this.tokens.name, this, "no-nullable-dict-arg", message); + } else if (!this.optional) { + if (this.parent && !dictionaryIncludesRequiredField(result.dictionary, defs) && isLastRequiredArgument(this)) { + const message = `Dictionary argument must be optional if it has no required fields`; + yield validationError(this.tokens.name, this, "dict-arg-optional", message, { + autofix: autofixDictionaryArgumentOptionality(this) + }); + } + } else if (!this.default) { + const message = `Optional dictionary arguments must have a default value of \`{}\`.`; + yield validationError(this.tokens.name, this, "dict-arg-default", message, { + autofix: autofixOptionalDictionaryDefaultValue(this) + }); + } + } + } + } + + /** + * @param {Argument} arg + */ + function isLastRequiredArgument(arg) { + const list = arg.parent.arguments || arg.parent.list; + const index = list.indexOf(arg); + const requiredExists = list.slice(index + 1).some(a => !a.optional); + return !requiredExists; + } + + /** + * @param {Argument} arg + */ + function autofixDictionaryArgumentOptionality(arg) { + return () => { + const firstToken = getFirstToken(arg.idlType); + arg.tokens.optional = { type: "optional", value: "optional", trivia: firstToken.trivia }; + firstToken.trivia = " "; + autofixOptionalDictionaryDefaultValue(arg)(); + }; + } + + /** + * @param {Argument} arg + */ + function autofixOptionalDictionaryDefaultValue(arg) { + return () => { + arg.default = Default.parse(new Tokeniser(" = {}")); + }; + } + + class Operation extends Base { + /** + * @typedef {import("../tokeniser.js").Token} Token + * + * @param {import("../tokeniser.js").Tokeniser} tokeniser + * @param {object} [options] + * @param {Token} [options.special] + * @param {Token} [options.regular] + */ + static parse(tokeniser, { special, regular } = {}) { + const tokens = { special }; + const ret = autoParenter(new Operation({ source: tokeniser.source, tokens })); + if (special && special.value === "stringifier") { + tokens.termination = tokeniser.consume(";"); + if (tokens.termination) { + ret.arguments = []; + return ret; + } + } + if (!special && !regular) { + tokens.special = tokeniser.consume("getter", "setter", "deleter"); + } + ret.idlType = return_type(tokeniser) || tokeniser.error("Missing return type"); + tokens.name = tokeniser.consume("identifier", "includes"); + tokens.open = tokeniser.consume("(") || tokeniser.error("Invalid operation"); + ret.arguments = argument_list(tokeniser); + tokens.close = tokeniser.consume(")") || tokeniser.error("Unterminated operation"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated operation, expected `;`"); + return ret.this; + } + + get type() { + return "operation"; + } + get name() { + const { name } = this.tokens; + if (!name) { + return ""; + } + return unescape(name.value); + } + get special() { + if (!this.tokens.special) { + return ""; + } + return this.tokens.special.value; + } + + *validate(defs) { + if (!this.name && ["", "static"].includes(this.special)) { + const message = `Regular or static operations must have both a return type and an identifier.`; + yield validationError(this.tokens.open, this, "incomplete-op", message); + } + if (this.idlType) { + yield* this.idlType.validate(defs); + } + for (const argument of this.arguments) { + yield* argument.validate(defs); + } + } + } + + class Attribute extends Base { + /** + * @param {import("../tokeniser.js").Tokeniser} tokeniser + */ + static parse(tokeniser, { special, noInherit = false, readonly = false } = {}) { + const start_position = tokeniser.position; + const tokens = { special }; + const ret = autoParenter(new Attribute({ source: tokeniser.source, tokens })); + if (!special && !noInherit) { + tokens.special = tokeniser.consume("inherit"); + } + if (ret.special === "inherit" && tokeniser.probe("readonly")) { + tokeniser.error("Inherited attributes cannot be read-only"); + } + tokens.readonly = tokeniser.consume("readonly"); + if (readonly && !tokens.readonly && tokeniser.probe("attribute")) { + tokeniser.error("Attributes must be readonly in this context"); + } + tokens.base = tokeniser.consume("attribute"); + if (!tokens.base) { + tokeniser.unconsume(start_position); + return; + } + ret.idlType = type_with_extended_attributes(tokeniser, "attribute-type") || tokeniser.error("Attribute lacks a type"); + switch (ret.idlType.generic) { + case "sequence": + case "record": tokeniser.error(`Attributes cannot accept ${ret.idlType.generic} types`); + } + tokens.name = tokeniser.consume("identifier", "async", "required") || tokeniser.error("Attribute lacks a name"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated attribute, expected `;`"); + return ret.this; + } + + get type() { + return "attribute"; + } + get special() { + if (!this.tokens.special) { + return ""; + } + return this.tokens.special.value; + } + get readonly() { + return !!this.tokens.readonly; + } + get name() { + return unescape(this.tokens.name.value); + } + + *validate(defs) { + yield* this.extAttrs.validate(defs); + yield* this.idlType.validate(defs); + } + } + + /** + * @param {string} identifier + */ + function unescape(identifier) { + return identifier.startsWith('_') ? identifier.slice(1) : identifier; + } + + /** + * Parses comma-separated list + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {object} args + * @param {Function} args.parser parser function for each item + * @param {boolean} [args.allowDangler] whether to allow dangling comma + * @param {string} [args.listName] the name to be shown on error messages + */ + function list(tokeniser, { parser, allowDangler, listName = "list" }) { + const first = parser(tokeniser); + if (!first) { + return []; + } + first.tokens.separator = tokeniser.consume(","); + const items = [first]; + while (first.tokens.separator) { + const item = parser(tokeniser); + if (!item) { + if (!allowDangler) { + tokeniser.error(`Trailing comma in ${listName}`); + } + break; + } + item.tokens.separator = tokeniser.consume(","); + items.push(item); + if (!item.tokens.separator) break; + } + return items; + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function const_value(tokeniser) { + return tokeniser.consume("true", "false", "Infinity", "-Infinity", "NaN", "decimal", "integer"); + } + + /** + * @param {object} token + * @param {string} token.type + * @param {string} token.value + */ + function const_data({ type, value }) { + switch (type) { + case "true": + case "false": + return { type: "boolean", value: type === "true" }; + case "Infinity": + case "-Infinity": + return { type: "Infinity", negative: type.startsWith("-") }; + case "[": + return { type: "sequence", value: [] }; + case "{": + return { type: "dictionary" }; + case "decimal": + case "integer": + return { type: "number", value }; + case "string": + return { type: "string", value: value.slice(1, -1) }; + default: + return { type }; + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function primitive_type(tokeniser) { + function integer_type() { + const prefix = tokeniser.consume("unsigned"); + const base = tokeniser.consume("short", "long"); + if (base) { + const postfix = tokeniser.consume("long"); + return new Type({ source, tokens: { prefix, base, postfix } }); + } + if (prefix) tokeniser.error("Failed to parse integer type"); + } + + function decimal_type() { + const prefix = tokeniser.consume("unrestricted"); + const base = tokeniser.consume("float", "double"); + if (base) { + return new Type({ source, tokens: { prefix, base } }); + } + if (prefix) tokeniser.error("Failed to parse float type"); + } + + const { source } = tokeniser; + const num_type = integer_type() || decimal_type(); + if (num_type) return num_type; + const base = tokeniser.consume("boolean", "byte", "octet"); + if (base) { + return new Type({ source, tokens: { base } }); + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function argument_list(tokeniser) { + return list(tokeniser, { parser: Argument.parse, listName: "arguments list" }); + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} typeName + */ + function type_with_extended_attributes(tokeniser, typeName) { + const extAttrs = ExtendedAttributes.parse(tokeniser); + const ret = Type.parse(tokeniser, typeName); + if (ret) autoParenter(ret).extAttrs = extAttrs; + return ret; + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {string} typeName + */ + function return_type(tokeniser, typeName) { + const typ = Type.parse(tokeniser, typeName || "return-type"); + if (typ) { + return typ; + } + const voidToken = tokeniser.consume("void"); + if (voidToken) { + const ret = new Type({ source: tokeniser.source, tokens: { base: voidToken } }); + ret.type = "return-type"; + return ret; + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function stringifier(tokeniser) { + const special = tokeniser.consume("stringifier"); + if (!special) return; + const member = Attribute.parse(tokeniser, { special }) || + Operation.parse(tokeniser, { special }) || + tokeniser.error("Unterminated stringifier"); + return member; + } + + /** + * @param {string} str + */ + function getLastIndentation(str) { + const lines = str.split("\n"); + // the first line visually binds to the preceding token + if (lines.length) { + const match = lines[lines.length - 1].match(/^\s+/); + if (match) { + return match[0]; + } + } + return ""; + } + + /** + * @param {string} parentTrivia + */ + function getMemberIndentation(parentTrivia) { + const indentation = getLastIndentation(parentTrivia); + const indentCh = indentation.includes("\t") ? "\t" : " "; + return indentation + indentCh; + } + + /** + * @param {object} def + * @param {import("./extended-attributes.js").ExtendedAttributes} def.extAttrs + */ + function autofixAddExposedWindow(def) { + return () => { + if (def.extAttrs.length){ + const tokeniser = new Tokeniser("Exposed=Window,"); + const exposed = SimpleExtendedAttribute.parse(tokeniser); + exposed.tokens.separator = tokeniser.consume(","); + const existing = def.extAttrs[0]; + if (!/^\s/.test(existing.tokens.name.trivia)) { + existing.tokens.name.trivia = ` ${existing.tokens.name.trivia}`; + } + def.extAttrs.unshift(exposed); + } else { + autoParenter(def).extAttrs = ExtendedAttributes.parse(new Tokeniser("[Exposed=Window]")); + const trivia = def.tokens.base.trivia; + def.extAttrs.tokens.open.trivia = trivia; + def.tokens.base.trivia = `\n${getLastIndentation(trivia)}`; + } + }; + } + + /** + * Get the first syntax token for the given IDL object. + * @param {*} data + */ + function getFirstToken(data) { + if (data.extAttrs.length) { + return data.extAttrs.tokens.open; + } + if (data.type === "operation" && !data.special) { + return getFirstToken(data.idlType); + } + const tokens = Object.values(data.tokens).sort((x, y) => x.index - y.index); + return tokens[0]; + } + + /** + * @template T + * @param {T[]} array + * @param {(item: T) => boolean} predicate + */ + function findLastIndex(array, predicate) { + const index = array.slice().reverse().findIndex(predicate); + if (index === -1) { + return index; + } + return array.length - index - 1; + } + + /** + * Returns a proxy that auto-assign `parent` field. + * @template T + * @param {T} data + * @param {*} [parent] The object that will be assigned to `parent`. + * If absent, it will be `data` by default. + * @return {T} + */ + function autoParenter(data, parent) { + if (!parent) { + // Defaults to `data` unless specified otherwise. + parent = data; + } + if (!data) { + // This allows `autoParenter(undefined)` which again allows + // `autoParenter(parse())` where the function may return nothing. + return data; + } + return new Proxy(data, { + get(target, p) { + const value = target[p]; + if (Array.isArray(value)) { + // Wraps the array so that any added items will also automatically + // get their `parent` values. + return autoParenter(value, target); + } + return value; + }, + set(target, p, value) { + target[p] = value; + if (!value) { + return true; + } else if (Array.isArray(value)) { + // Assigning an array will add `parent` to its items. + for (const item of value) { + if (typeof item.parent !== "undefined") { + item.parent = parent; + } + } + } else if (typeof value.parent !== "undefined") { + value.parent = parent; + } + return true; + } + }); + } + + // These regular expressions use the sticky flag so they will only match at + // the current location (ie. the offset of lastIndex). + const tokenRe = { + // This expression uses a lookahead assertion to catch false matches + // against integers early. + "decimal": /-?(?=[0-9]*\.|[0-9]+[eE])(([0-9]+\.[0-9]*|[0-9]*\.[0-9]+)([Ee][-+]?[0-9]+)?|[0-9]+[Ee][-+]?[0-9]+)/y, + "integer": /-?(0([Xx][0-9A-Fa-f]+|[0-7]*)|[1-9][0-9]*)/y, + "identifier": /[_-]?[A-Za-z][0-9A-Z_a-z-]*/y, + "string": /"[^"]*"/y, + "whitespace": /[\t\n\r ]+/y, + "comment": /((\/(\/.*|\*([^*]|\*[^/])*\*\/)[\t\n\r ]*)+)/y, + "other": /[^\t\n\r 0-9A-Za-z]/y + }; + + const typeNameKeywords = [ + "ArrayBuffer", + "DataView", + "Int8Array", + "Int16Array", + "Int32Array", + "Uint8Array", + "Uint16Array", + "Uint32Array", + "Uint8ClampedArray", + "Float32Array", + "Float64Array", + "any", + "object", + "symbol" + ]; + + const stringTypes = [ + "ByteString", + "DOMString", + "USVString" + ]; + + const argumentNameKeywords = [ + "async", + "attribute", + "callback", + "const", + "constructor", + "deleter", + "dictionary", + "enum", + "getter", + "includes", + "inherit", + "interface", + "iterable", + "maplike", + "namespace", + "partial", + "required", + "setlike", + "setter", + "static", + "stringifier", + "typedef", + "unrestricted" + ]; + + const nonRegexTerminals = [ + "-Infinity", + "FrozenArray", + "Infinity", + "NaN", + "Promise", + "boolean", + "byte", + "double", + "false", + "float", + "long", + "mixin", + "null", + "octet", + "optional", + "or", + "readonly", + "record", + "sequence", + "short", + "true", + "unsigned", + "void" + ].concat(argumentNameKeywords, stringTypes, typeNameKeywords); + + const punctuations = [ + "(", + ")", + ",", + "...", + ":", + ";", + "<", + "=", + ">", + "?", + "[", + "]", + "{", + "}" + ]; + + const reserved = [ + // "constructor" is now a keyword + "_constructor", + "toString", + "_toString", + ]; + + /** + * @typedef {ArrayItemType>} Token + * @param {string} str + */ + function tokenise(str) { + const tokens = []; + let lastCharIndex = 0; + let trivia = ""; + let line = 1; + let index = 0; + while (lastCharIndex < str.length) { + const nextChar = str.charAt(lastCharIndex); + let result = -1; + + if (/[\t\n\r ]/.test(nextChar)) { + result = attemptTokenMatch("whitespace", { noFlushTrivia: true }); + } else if (nextChar === '/') { + result = attemptTokenMatch("comment", { noFlushTrivia: true }); + } + + if (result !== -1) { + const currentTrivia = tokens.pop().value; + line += (currentTrivia.match(/\n/g) || []).length; + trivia += currentTrivia; + index -= 1; + } else if (/[-0-9.A-Z_a-z]/.test(nextChar)) { + result = attemptTokenMatch("decimal"); + if (result === -1) { + result = attemptTokenMatch("integer"); + } + if (result === -1) { + result = attemptTokenMatch("identifier"); + const lastIndex = tokens.length - 1; + const token = tokens[lastIndex]; + if (result !== -1) { + if (reserved.includes(token.value)) { + const message = `${unescape(token.value)} is a reserved identifier and must not be used.`; + throw new WebIDLParseError(syntaxError(tokens, lastIndex, null, message)); + } else if (nonRegexTerminals.includes(token.value)) { + token.type = token.value; + } + } + } + } else if (nextChar === '"') { + result = attemptTokenMatch("string"); + } + + for (const punctuation of punctuations) { + if (str.startsWith(punctuation, lastCharIndex)) { + tokens.push({ type: punctuation, value: punctuation, trivia, line, index }); + trivia = ""; + lastCharIndex += punctuation.length; + result = lastCharIndex; + break; + } + } + + // other as the last try + if (result === -1) { + result = attemptTokenMatch("other"); + } + if (result === -1) { + throw new Error("Token stream not progressing"); + } + lastCharIndex = result; + index += 1; + } + + // remaining trivia as eof + tokens.push({ + type: "eof", + value: "", + trivia + }); + + return tokens; + + /** + * @param {keyof typeof tokenRe} type + * @param {object} options + * @param {boolean} [options.noFlushTrivia] + */ + function attemptTokenMatch(type, { noFlushTrivia } = {}) { + const re = tokenRe[type]; + re.lastIndex = lastCharIndex; + const result = re.exec(str); + if (result) { + tokens.push({ type, value: result[0], trivia, line, index }); + if (!noFlushTrivia) { + trivia = ""; + } + return re.lastIndex; + } + return -1; + } + } + + class Tokeniser { + /** + * @param {string} idl + */ + constructor(idl) { + this.source = tokenise(idl); + this.position = 0; + } + + /** + * @param {string} message + * @return {never} + */ + error(message) { + throw new WebIDLParseError(syntaxError(this.source, this.position, this.current, message)); + } + + /** + * @param {string} type + */ + probe(type) { + return this.source.length > this.position && this.source[this.position].type === type; + } + + /** + * @param {...string} candidates + */ + consume(...candidates) { + for (const type of candidates) { + if (!this.probe(type)) continue; + const token = this.source[this.position]; + this.position++; + return token; + } + } + + /** + * @param {number} position + */ + unconsume(position) { + this.position = position; + } + } + + class WebIDLParseError extends Error { + /** + * @param {object} options + * @param {string} options.message + * @param {string} options.bareMessage + * @param {string} options.context + * @param {number} options.line + * @param {*} options.sourceName + * @param {string} options.input + * @param {*[]} options.tokens + */ + constructor({ message, bareMessage, context, line, sourceName, input, tokens }) { + super(message); + + this.name = "WebIDLParseError"; // not to be mangled + this.bareMessage = bareMessage; + this.context = context; + this.line = line; + this.sourceName = sourceName; + this.input = input; + this.tokens = tokens; + } + } + + class EnumValue extends Token { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const value = tokeniser.consume("string"); + if (value) { + return new EnumValue({ source: tokeniser.source, tokens: { value } }); + } + } + + get type() { + return "enum-value"; + } + get value() { + return super.value.slice(1, -1); + } + } + + class Enum extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + /** @type {Base["tokens"]} */ + const tokens = {}; + tokens.base = tokeniser.consume("enum"); + if (!tokens.base) { + return; + } + tokens.name = tokeniser.consume("identifier") || tokeniser.error("No name for enum"); + const ret = autoParenter(new Enum({ source: tokeniser.source, tokens })); + tokeniser.current = ret.this; + tokens.open = tokeniser.consume("{") || tokeniser.error("Bodyless enum"); + ret.values = list(tokeniser, { + parser: EnumValue.parse, + allowDangler: true, + listName: "enumeration" + }); + if (tokeniser.probe("string")) { + tokeniser.error("No comma between enum values"); + } + tokens.close = tokeniser.consume("}") || tokeniser.error("Unexpected value in enum"); + if (!ret.values.length) { + tokeniser.error("No value in enum"); + } + tokens.termination = tokeniser.consume(";") || tokeniser.error("No semicolon after enum"); + return ret.this; + } + + get type() { + return "enum"; + } + get name() { + return unescape(this.tokens.name.value); + } + } + + // @ts-check + + class Includes extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const target = tokeniser.consume("identifier"); + if (!target) { + return; + } + const tokens = { target }; + tokens.includes = tokeniser.consume("includes"); + if (!tokens.includes) { + tokeniser.unconsume(target.index); + return; + } + tokens.mixin = tokeniser.consume("identifier") || tokeniser.error("Incomplete includes statement"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("No terminating ; for includes statement"); + return new Includes({ source: tokeniser.source, tokens }); + } + + get type() { + return "includes"; + } + get target() { + return unescape(this.tokens.target.value); + } + get includes() { + return unescape(this.tokens.mixin.value); + } + } + + class Typedef extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + /** @type {Base["tokens"]} */ + const tokens = {}; + const ret = autoParenter(new Typedef({ source: tokeniser.source, tokens })); + tokens.base = tokeniser.consume("typedef"); + if (!tokens.base) { + return; + } + ret.idlType = type_with_extended_attributes(tokeniser, "typedef-type") || tokeniser.error("Typedef lacks a type"); + tokens.name = tokeniser.consume("identifier") || tokeniser.error("Typedef lacks a name"); + tokeniser.current = ret.this; + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated typedef, expected `;`"); + return ret.this; + } + + get type() { + return "typedef"; + } + get name() { + return unescape(this.tokens.name.value); + } + + *validate(defs) { + yield* this.idlType.validate(defs); + } + } + + class CallbackFunction extends Base { + /** + * @param {import("../tokeniser.js").Tokeniser} tokeniser + */ + static parse(tokeniser, base) { + const tokens = { base }; + const ret = autoParenter(new CallbackFunction({ source: tokeniser.source, tokens })); + tokens.name = tokeniser.consume("identifier") || tokeniser.error("Callback lacks a name"); + tokeniser.current = ret.this; + tokens.assign = tokeniser.consume("=") || tokeniser.error("Callback lacks an assignment"); + ret.idlType = return_type(tokeniser) || tokeniser.error("Callback lacks a return type"); + tokens.open = tokeniser.consume("(") || tokeniser.error("Callback lacks parentheses for arguments"); + ret.arguments = argument_list(tokeniser); + tokens.close = tokeniser.consume(")") || tokeniser.error("Unterminated callback"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated callback, expected `;`"); + return ret.this; + } + + get type() { + return "callback"; + } + get name() { + return unescape(this.tokens.name.value); + } + + *validate(defs) { + yield* this.extAttrs.validate(defs); + yield* this.idlType.validate(defs); + } + } + + /** + * @param {import("../tokeniser.js").Tokeniser} tokeniser + */ + function inheritance(tokeniser) { + const colon = tokeniser.consume(":"); + if (!colon) { + return {}; + } + const inheritance = tokeniser.consume("identifier") || tokeniser.error("Inheritance lacks a type"); + return { colon, inheritance }; + } + + class Container extends Base { + /** + * @template T + * @param {import("../tokeniser.js").Tokeniser} tokeniser + * @param {T} instance + * @param {*} args + */ + static parse(tokeniser, instance, { type, inheritable, allowedMembers }) { + const { tokens } = instance; + tokens.name = tokeniser.consume("identifier") || tokeniser.error(`Missing name in ${instance.type}`); + tokeniser.current = instance; + instance = autoParenter(instance); + if (inheritable) { + Object.assign(tokens, inheritance(tokeniser)); + } + tokens.open = tokeniser.consume("{") || tokeniser.error(`Bodyless ${type}`); + instance.members = []; + while (true) { + tokens.close = tokeniser.consume("}"); + if (tokens.close) { + tokens.termination = tokeniser.consume(";") || tokeniser.error(`Missing semicolon after ${type}`); + return instance.this; + } + const ea = ExtendedAttributes.parse(tokeniser); + let mem; + for (const [parser, ...args] of allowedMembers) { + mem = autoParenter(parser(tokeniser, ...args)); + if (mem) { + break; + } + } + if (!mem) { + tokeniser.error("Unknown member"); + } + mem.extAttrs = ea; + instance.members.push(mem.this); + } + } + + get partial() { + return !!this.tokens.partial; + } + get name() { + return unescape(this.tokens.name.value); + } + get inheritance() { + if (!this.tokens.inheritance) { + return null; + } + return unescape(this.tokens.inheritance.value); + } + + *validate(defs) { + for (const member of this.members) { + if (member.validate) { + yield* member.validate(defs); + } + } + } + } + + class Constant extends Base { + /** + * @param {import("../tokeniser.js").Tokeniser} tokeniser + */ + static parse(tokeniser) { + /** @type {Base["tokens"]} */ + const tokens = {}; + tokens.base = tokeniser.consume("const"); + if (!tokens.base) { + return; + } + let idlType = primitive_type(tokeniser); + if (!idlType) { + const base = tokeniser.consume("identifier") || tokeniser.error("Const lacks a type"); + idlType = new Type({ source: tokeniser.source, tokens: { base } }); + } + if (tokeniser.probe("?")) { + tokeniser.error("Unexpected nullable constant type"); + } + idlType.type = "const-type"; + tokens.name = tokeniser.consume("identifier") || tokeniser.error("Const lacks a name"); + tokens.assign = tokeniser.consume("=") || tokeniser.error("Const lacks value assignment"); + tokens.value = const_value(tokeniser) || tokeniser.error("Const lacks a value"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated const, expected `;`"); + const ret = new Constant({ source: tokeniser.source, tokens }); + autoParenter(ret).idlType = idlType; + return ret; + } + + get type() { + return "const"; + } + get name() { + return unescape(this.tokens.name.value); + } + get value() { + return const_data(this.tokens.value); + } + } + + class IterableLike extends Base { + /** + * @param {import("../tokeniser.js").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const start_position = tokeniser.position; + const tokens = {}; + const ret = autoParenter(new IterableLike({ source: tokeniser.source, tokens })); + tokens.readonly = tokeniser.consume("readonly"); + if (!tokens.readonly) { + tokens.async = tokeniser.consume("async"); + } + tokens.base = + tokens.readonly ? tokeniser.consume("maplike", "setlike") : + tokens.async ? tokeniser.consume("iterable") : + tokeniser.consume("iterable", "maplike", "setlike"); + if (!tokens.base) { + tokeniser.unconsume(start_position); + return; + } + + const { type } = ret; + const secondTypeRequired = type === "maplike"; + const secondTypeAllowed = secondTypeRequired || type === "iterable"; + const argumentAllowed = ret.async && type === "iterable"; + + tokens.open = tokeniser.consume("<") || tokeniser.error(`Missing less-than sign \`<\` in ${type} declaration`); + const first = type_with_extended_attributes(tokeniser) || tokeniser.error(`Missing a type argument in ${type} declaration`); + ret.idlType = [first]; + ret.arguments = []; + + if (secondTypeAllowed) { + first.tokens.separator = tokeniser.consume(","); + if (first.tokens.separator) { + ret.idlType.push(type_with_extended_attributes(tokeniser)); + } + else if (secondTypeRequired) { + tokeniser.error(`Missing second type argument in ${type} declaration`); + } + } + + tokens.close = tokeniser.consume(">") || tokeniser.error(`Missing greater-than sign \`>\` in ${type} declaration`); + + if (tokeniser.probe("(")) { + if (argumentAllowed) { + tokens.argsOpen = tokeniser.consume("("); + ret.arguments.push(...argument_list(tokeniser)); + tokens.argsClose = tokeniser.consume(")") || tokeniser.error("Unterminated async iterable argument list"); + } else { + tokeniser.error(`Arguments are only allowed for \`async iterable\``); + } + } + + tokens.termination = tokeniser.consume(";") || tokeniser.error(`Missing semicolon after ${type} declaration`); + + return ret.this; + } + + get type() { + return this.tokens.base.value; + } + get readonly() { + return !!this.tokens.readonly; + } + get async() { + return !!this.tokens.async; + } + + *validate(defs) { + for (const type of this.idlType) { + yield* type.validate(defs); + } + for (const argument of this.arguments) { + yield* argument.validate(defs); + } + } + } + + // @ts-check + + function* checkInterfaceMemberDuplication(defs, i) { + const opNames = new Set(getOperations(i).map(op => op.name)); + const partials = defs.partials.get(i.name) || []; + const mixins = defs.mixinMap.get(i.name) || []; + for (const ext of [...partials, ...mixins]) { + const additions = getOperations(ext); + yield* forEachExtension(additions, opNames, ext, i); + for (const addition of additions) { + opNames.add(addition.name); + } + } + + function* forEachExtension(additions, existings, ext, base) { + for (const addition of additions) { + const { name } = addition; + if (name && existings.has(name)) { + const message = `The operation "${name}" has already been defined for the base interface "${base.name}" either in itself or in a mixin`; + yield validationError(addition.tokens.name, ext, "no-cross-overload", message); + } + } + } + + function getOperations(i) { + return i.members + .filter(({type}) => type === "operation"); + } + } + + class Constructor extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + const base = tokeniser.consume("constructor"); + if (!base) { + return; + } + /** @type {Base["tokens"]} */ + const tokens = { base }; + tokens.open = tokeniser.consume("(") || tokeniser.error("No argument list in constructor"); + const args = argument_list(tokeniser); + tokens.close = tokeniser.consume(")") || tokeniser.error("Unterminated constructor"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("No semicolon after constructor"); + const ret = new Constructor({ source: tokeniser.source, tokens }); + autoParenter(ret).arguments = args; + return ret; + } + + get type() { + return "constructor"; + } + + *validate(defs) { + if (this.idlType) { + yield* this.idlType.validate(defs); + } + for (const argument of this.arguments) { + yield* argument.validate(defs); + } + } + } + + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + function static_member(tokeniser) { + const special = tokeniser.consume("static"); + if (!special) return; + const member = Attribute.parse(tokeniser, { special }) || + Operation.parse(tokeniser, { special }) || + tokeniser.error("No body in static member"); + return member; + } + + class Interface extends Container { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser, base, { partial = null } = {}) { + const tokens = { partial, base }; + return Container.parse(tokeniser, new Interface({ source: tokeniser.source, tokens }), { + type: "interface", + inheritable: !partial, + allowedMembers: [ + [Constant.parse], + [Constructor.parse], + [static_member], + [stringifier], + [IterableLike.parse], + [Attribute.parse], + [Operation.parse] + ] + }); + } + + get type() { + return "interface"; + } + + *validate(defs) { + yield* this.extAttrs.validate(defs); + if ( + !this.partial && + this.extAttrs.every(extAttr => extAttr.name !== "Exposed") && + this.extAttrs.every(extAttr => extAttr.name !== "LegacyNoInterfaceObject") + ) { + const message = `Interfaces must have \`[Exposed]\` extended attribute. \ +To fix, add, for example, \`[Exposed=Window]\`. Please also consider carefully \ +if your interface should also be exposed in a Worker scope. Refer to the \ +[WebIDL spec section on Exposed](https://heycam.github.io/webidl/#Exposed) \ +for more information.`; + yield validationError(this.tokens.name, this, "require-exposed", message, { + autofix: autofixAddExposedWindow(this) + }); + } + const oldConstructors = this.extAttrs.filter(extAttr => extAttr.name === "Constructor"); + for (const constructor of oldConstructors) { + const message = `Constructors should now be represented as a \`constructor()\` operation on the interface \ +instead of \`[Constructor]\` extended attribute. Refer to the \ +[WebIDL spec section on constructor operations](https://heycam.github.io/webidl/#idl-constructors) \ +for more information.`; + yield validationError(constructor.tokens.name, this, "constructor-member", message, { + autofix: autofixConstructor(this, constructor) + }); + } + + const isGlobal = this.extAttrs.some(extAttr => extAttr.name === "Global"); + if (isGlobal) { + const factoryFunctions = this.extAttrs.filter(extAttr => extAttr.name === "LegacyFactoryFunction"); + for (const named of factoryFunctions) { + const message = `Interfaces marked as \`[Global]\` cannot have factory functions.`; + yield validationError(named.tokens.name, this, "no-constructible-global", message); + } + + const constructors = this.members.filter(member => member.type === "constructor"); + for (const named of constructors) { + const message = `Interfaces marked as \`[Global]\` cannot have constructors.`; + yield validationError(named.tokens.base, this, "no-constructible-global", message); + } + } + + yield* super.validate(defs); + if (!this.partial) { + yield* checkInterfaceMemberDuplication(defs, this); + } + } + } + + function autofixConstructor(interfaceDef, constructorExtAttr) { + interfaceDef = autoParenter(interfaceDef); + return () => { + const indentation = getLastIndentation(interfaceDef.extAttrs.tokens.open.trivia); + const memberIndent = interfaceDef.members.length ? + getLastIndentation(getFirstToken(interfaceDef.members[0]).trivia) : + getMemberIndentation(indentation); + const constructorOp = Constructor.parse(new Tokeniser(`\n${memberIndent}constructor();`)); + constructorOp.extAttrs = new ExtendedAttributes({}); + autoParenter(constructorOp).arguments = constructorExtAttr.arguments; + + const existingIndex = findLastIndex(interfaceDef.members, m => m.type === "constructor"); + interfaceDef.members.splice(existingIndex + 1, 0, constructorOp); + + const { close } = interfaceDef.tokens; + if (!close.trivia.includes("\n")) { + close.trivia += `\n${indentation}`; + } + + const { extAttrs } = interfaceDef; + const index = extAttrs.indexOf(constructorExtAttr); + const removed = extAttrs.splice(index, 1); + if (!extAttrs.length) { + extAttrs.tokens.open = extAttrs.tokens.close = undefined; + } else if (extAttrs.length === index) { + extAttrs[index - 1].tokens.separator = undefined; + } else if (!extAttrs[index].tokens.name.trivia.trim()) { + extAttrs[index].tokens.name.trivia = removed[0].tokens.name.trivia; + } + }; + } + + class Mixin extends Container { + /** + * @typedef {import("../tokeniser.js").Token} Token + * + * @param {import("../tokeniser.js").Tokeniser} tokeniser + * @param {Token} base + * @param {object} [options] + * @param {Token} [options.partial] + */ + static parse(tokeniser, base, { partial } = {}) { + const tokens = { partial, base }; + tokens.mixin = tokeniser.consume("mixin"); + if (!tokens.mixin) { + return; + } + return Container.parse(tokeniser, new Mixin({ source: tokeniser.source, tokens }), { + type: "interface mixin", + allowedMembers: [ + [Constant.parse], + [stringifier], + [Attribute.parse, { noInherit: true }], + [Operation.parse, { regular: true }] + ] + }); + } + + get type() { + return "interface mixin"; + } + } + + class Field extends Base { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser) { + /** @type {Base["tokens"]} */ + const tokens = {}; + const ret = autoParenter(new Field({ source: tokeniser.source, tokens })); + ret.extAttrs = ExtendedAttributes.parse(tokeniser); + tokens.required = tokeniser.consume("required"); + ret.idlType = type_with_extended_attributes(tokeniser, "dictionary-type") || tokeniser.error("Dictionary member lacks a type"); + tokens.name = tokeniser.consume("identifier") || tokeniser.error("Dictionary member lacks a name"); + ret.default = Default.parse(tokeniser); + if (tokens.required && ret.default) tokeniser.error("Required member must not have a default"); + tokens.termination = tokeniser.consume(";") || tokeniser.error("Unterminated dictionary member, expected `;`"); + return ret.this; + } + + get type() { + return "field"; + } + get name() { + return unescape(this.tokens.name.value); + } + get required() { + return !!this.tokens.required; + } + + *validate(defs) { + yield* this.idlType.validate(defs); + } + } + + // @ts-check + + class Dictionary extends Container { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {object} [options] + * @param {import("../tokeniser.js").Token} [options.partial] + */ + static parse(tokeniser, { partial } = {}) { + const tokens = { partial }; + tokens.base = tokeniser.consume("dictionary"); + if (!tokens.base) { + return; + } + return Container.parse(tokeniser, new Dictionary({ source: tokeniser.source, tokens }), { + type: "dictionary", + inheritable: !partial, + allowedMembers: [ + [Field.parse], + ] + }); + } + + get type() { + return "dictionary"; + } + } + + class Namespace extends Container { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + * @param {object} [options] + * @param {import("../tokeniser.js").Token} [options.partial] + */ + static parse(tokeniser, { partial } = {}) { + const tokens = { partial }; + tokens.base = tokeniser.consume("namespace"); + if (!tokens.base) { + return; + } + return Container.parse(tokeniser, new Namespace({ source: tokeniser.source, tokens }), { + type: "namespace", + allowedMembers: [ + [Attribute.parse, { noInherit: true, readonly: true }], + [Operation.parse, { regular: true }] + ] + }); + } + + get type() { + return "namespace"; + } + + *validate(defs) { + if (!this.partial && this.extAttrs.every(extAttr => extAttr.name !== "Exposed")) { + const message = `Namespaces must have [Exposed] extended attribute. \ +To fix, add, for example, [Exposed=Window]. Please also consider carefully \ +if your namespace should also be exposed in a Worker scope. Refer to the \ +[WebIDL spec section on Exposed](https://heycam.github.io/webidl/#Exposed) \ +for more information.`; + yield validationError(this.tokens.name, this, "require-exposed", message, { + autofix: autofixAddExposedWindow(this) + }); + } + yield* super.validate(defs); + } + } + + // @ts-check + + class CallbackInterface extends Container { + /** + * @param {import("../tokeniser").Tokeniser} tokeniser + */ + static parse(tokeniser, callback, { partial = null } = {}) { + const tokens = { callback }; + tokens.base = tokeniser.consume("interface"); + if (!tokens.base) { + return; + } + return Container.parse(tokeniser, new CallbackInterface({ source: tokeniser.source, tokens }), { + type: "callback interface", + inheritable: !partial, + allowedMembers: [ + [Constant.parse], + [Operation.parse, { regular: true }] + ] + }); + } + + get type() { + return "callback interface"; + } + } + + /** + * @param {Tokeniser} tokeniser + * @param {object} options + * @param {boolean} [options.concrete] + */ + function parseByTokens(tokeniser, options) { + const source = tokeniser.source; + + function error(str) { + tokeniser.error(str); + } + + function consume(...candidates) { + return tokeniser.consume(...candidates); + } + + function callback() { + const callback = consume("callback"); + if (!callback) return; + if (tokeniser.probe("interface")) { + return CallbackInterface.parse(tokeniser, callback); + } + return CallbackFunction.parse(tokeniser, callback); + } + + function interface_(opts) { + const base = consume("interface"); + if (!base) return; + const ret = Mixin.parse(tokeniser, base, opts) || + Interface.parse(tokeniser, base, opts) || + error("Interface has no proper body"); + return ret; + } + + function partial() { + const partial = consume("partial"); + if (!partial) return; + return Dictionary.parse(tokeniser, { partial }) || + interface_({ partial }) || + Namespace.parse(tokeniser, { partial }) || + error("Partial doesn't apply to anything"); + } + + function definition() { + return callback() || + interface_() || + partial() || + Dictionary.parse(tokeniser) || + Enum.parse(tokeniser) || + Typedef.parse(tokeniser) || + Includes.parse(tokeniser) || + Namespace.parse(tokeniser); + } + + function definitions() { + if (!source.length) return []; + const defs = []; + while (true) { + const ea = ExtendedAttributes.parse(tokeniser); + const def = definition(); + if (!def) { + if (ea.length) error("Stray extended attributes"); + break; + } + autoParenter(def).extAttrs = ea; + defs.push(def); + } + const eof = consume("eof"); + if (options.concrete) { + defs.push(eof); + } + return defs; + } + const res = definitions(); + if (tokeniser.position < source.length) error("Unrecognised tokens"); + return res; + } + + /** + * @param {string} str + * @param {object} [options] + * @param {*} [options.sourceName] + * @param {boolean} [options.concrete] + */ + function parse(str, options = {}) { + const tokeniser = new Tokeniser(str); + if (typeof options.sourceName !== "undefined") { + tokeniser.source.name = options.sourceName; + } + return parseByTokens(tokeniser, options); + } + /** * Extract definitions in the spec that follow the "Definitions data model": * https://tabatkins.github.io/bikeshed/#dfn-contract @@ -536,8 +2720,8 @@ function preProcessHTML() { // We need to extract the list of possible interfaces by parsing the WebIDL of the spec first - const idl = window.reffy.extractWebIdl(); - const idlTree = WebIDL2.parse(idl); + const idl = extractWebIdl(); + const idlTree = parse(idl); const idlInterfaces = idlTree.filter(item => item.type === "interface" || item.type === "interface mixin"); function fromIdToElement(id) { diff --git a/src/browserlib/extract-dfns.js b/src/browserlib/extract-dfns.js index 988c35d6..a7b34402 100644 --- a/src/browserlib/extract-dfns.js +++ b/src/browserlib/extract-dfns.js @@ -1,3 +1,5 @@ +import extractWebIdl from './extract-webidl.js'; +import {parse} from "../../node_modules/webidl2/index.js"; /** * Extract definitions in the spec that follow the "Definitions data model": * https://tabatkins.github.io/bikeshed/#dfn-contract @@ -104,8 +106,8 @@ export default function (spec) { function preProcessHTML() { // We need to extract the list of possible interfaces by parsing the WebIDL of the spec first - const idl = window.reffy.extractWebIdl(); - const idlTree = WebIDL2.parse(idl); + const idl = extractWebIdl(); + const idlTree = parse(idl); const idlInterfaces = idlTree.filter(item => item.type === "interface" || item.type === "interface mixin"); function fromIdToElement(id) { diff --git a/tests/extract-dfns.js b/tests/extract-dfns.js index 446fa461..d00c83cb 100644 --- a/tests/extract-dfns.js +++ b/tests/extract-dfns.js @@ -331,11 +331,6 @@ async function assertExtractedDefinition(browser, html, dfns, spec) { await page.addScriptTag({ path: path.resolve(__dirname, '../builds/browser.js') }); - if (spec === "html") { // we need to parse WebIDL to match names in the HTML Spec - await page.addScriptTag({ - path: path.resolve(__dirname, '../node_modules/webidl2/dist/webidl2.js') - }); - } const extractedDfns = await page.evaluate(async () => { return reffy.extractDefinitions(spec); From 9a57beecca3539ca6308e37273081a83680e4b7c Mon Sep 17 00:00:00 2001 From: Dominique Hazael-Massieux Date: Tue, 30 Jun 2020 09:20:04 +0200 Subject: [PATCH 4/5] Bring various cosmectic improvements identified in code review --- builds/browser.js | 61 ++++++++++++++++++++-------------- src/browserlib/extract-dfns.js | 61 ++++++++++++++++++++-------------- tests/extract-dfns.js | 2 +- 3 files changed, 73 insertions(+), 51 deletions(-) diff --git a/builds/browser.js b/builds/browser.js index 049b6f94..013f7779 100644 --- a/builds/browser.js +++ b/builds/browser.js @@ -2792,16 +2792,16 @@ for more information.`; function fromIdToTypeAndFor(containerid, id) { // deals with exceptions to how containerid / id are expected to be parsed if (id) { - [containerid, id] = { - "history-scroll": ["history", "scrollrestoration"], - // overloads - "document-open" : ["document", "open"], - "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], - "messageport-postmessage": ["messageport", "postmessage"], - "window-postmessage": ["window", "postmessage"], - "worker-postmessage": ["worker", "postmessage"], - "context-2d-settransform": ["context-2d", "settransform"] - }[containerid] || [containerid, id]; + [containerid, id] = { + "history-scroll": ["history", "scrollrestoration"], + // overloads + "document-open" : ["document", "open"], + "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], + "messageport-postmessage": ["messageport", "postmessage"], + "window-postmessage": ["window", "postmessage"], + "worker-postmessage": ["worker", "postmessage"], + "context-2d-settransform": ["context-2d", "settransform"] + }[containerid] || [containerid, id]; } @@ -2818,14 +2818,14 @@ for more information.`; "navigator": "Navigator" }; const fullId = containerid + "-" + id; - if (exceptions[fullId]|| fromIdToIdl(containerid)) { + if (exceptions[fullId] || fromIdToIdl(containerid)) { let names = (exceptions[fullId] ? exceptions[fullId] : fromIdToIdl(containerid)).split(","); interfaces = idlInterfaces.filter(i => names.includes(i.name)); } if (Object.keys(mixins).includes(containerid)) { - // some container id are split across several mixins, lets find out which + // some container ids are split across several mixins, let's find out which const candidateInterfaceNames = [mixins[containerid]].concat(idlTree.filter(inc => inc.type === "includes" && inc.target === mixins[containerid]).map(inc => inc.includes)); - interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); + interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); } if (interfaces.length) { @@ -2835,7 +2835,7 @@ for more information.`; type = "dfn"; // dom-head-profile, intentionally omitted from IDL fragment if (id === "profile" && containerid === "head") { - return {type: "attribute", _for:"HTMLHeadElement"}; + return {type: "attribute", _for: "HTMLHeadElement"}; } relevantInterfaces = interfaces.filter(iface => iface.members.find(member => member.name && member.name.toLowerCase() === id)); if (relevantInterfaces.length) { @@ -2846,14 +2846,16 @@ for more information.`; return {type, _for: [... new Set(relevantInterfaces.map(iface => iface.name))].join(",")}; } - const enumName = id => { switch(id) { + const enumName = id => { + switch(id) { case "context-2d-direction": return "CanvasDirection"; case "context-2d-fillrule": return "CanvasFillRule"; case "context-2d-imagesmoothingquality": return "ImageSmoothingQuality"; case "context-2d-textalign": return "CanvasTextAlign"; case "context-2d-textbaseline": return "CanvasTextBaseline"; - } - }; + } + }; + let _enum = idlTree.find(i => i.type === "enum" && (i.name.toLowerCase() === containerid || enumName(containerid) === i.name)); // TODO check the value is defined if (_enum) return {type: "enum-value", _for: _enum.name}; @@ -2865,19 +2867,19 @@ for more information.`; // Ideally, get this fixed upstream switch(containerid) { // not an enum, but a well-defined DOMString - case "datatransfer-dropeffect": return {type: "dfn", _for:"DataTransfer.dropEffect"}; + case "datatransfer-dropeffect": return {type: "dfn", _for: "DataTransfer.dropEffect"}; // not an enum, but a well-defined DOMString - case "datatransfer-effectallowed": return {type: "dfn", _for:"DataTransfer.effectAllowed"}; - case "document-nameditem": return {type: "dfn", _for:"Document"}; + case "datatransfer-effectallowed": return {type: "dfn", _for: "DataTransfer.effectAllowed"}; + case "document-nameditem": return {type: "dfn", _for: "Document"}; // mode of the value attribute of the inputelement case "input-value": case "input-value-default": - return {type: "dfn", _for:"HTMLInputElement.value"}; + return {type: "dfn", _for: "HTMLInputElement.value"}; // not an enum, but a well-defined DOMString - case "texttrack-kind": return {type: "dfn", _for:"TextTrack.kind"}; + case "texttrack-kind": return {type: "dfn", _for: "TextTrack.kind"}; // dom-tree-accessors - case "tree": return { type:"dfn", _for:""}; - case "window-nameditem": return {type: "dfn", _for:"Window"}; + case "tree": return { type:"dfn", _for: ""}; + case "window-nameditem": return {type: "dfn", _for: "Window"}; } //throw "Cannot match " + containerid + " to a known IDL name (" + id + ")"; @@ -3006,7 +3008,16 @@ for more information.`; } if ((m = el.id.match(/^dom-([^-]+)$/) || el.id.match(/^dom-([^-]+)-[0-9]+$/) || el.id.match(/^dom-([^-]+)-constructor$/))) { - const globalscopes = ["Window", "WindowOrWorkerGlobalScope", "HTMLElement", "WindowSessionStorage", "WorkerGlobalScope", "WindowLocalStorage", "HTMLOrSVGElement", "ElementContentEditable"]; + const globalscopes = [ + "ElementContentEditable", + "HTMLElement", + "HTMLOrSVGElement", + "Window", + "WindowLocalStorage", + "WindowOrWorkerGlobalScope", + "WindowSessionStorage", + "WorkerGlobalScope" + ]; const name = el.textContent.split('(')[0]; if (el.textContent.match(/\(/)) { // e.g. print(), Audio(src) diff --git a/src/browserlib/extract-dfns.js b/src/browserlib/extract-dfns.js index a7b34402..35f63f16 100644 --- a/src/browserlib/extract-dfns.js +++ b/src/browserlib/extract-dfns.js @@ -179,16 +179,16 @@ function preProcessHTML() { function fromIdToTypeAndFor(containerid, id) { // deals with exceptions to how containerid / id are expected to be parsed if (id) { - [containerid, id] = { - "history-scroll": ["history", "scrollrestoration"], - // overloads - "document-open" : ["document", "open"], - "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], - "messageport-postmessage": ["messageport", "postmessage"], - "window-postmessage": ["window", "postmessage"], - "worker-postmessage": ["worker", "postmessage"], - "context-2d-settransform": ["context-2d", "settransform"] - }[containerid] || [containerid, id]; + [containerid, id] = { + "history-scroll": ["history", "scrollrestoration"], + // overloads + "document-open" : ["document", "open"], + "dedicatedworkerglobalscope-postmessage": ["dedicatedworkerglobalscope", "postmessage"], + "messageport-postmessage": ["messageport", "postmessage"], + "window-postmessage": ["window", "postmessage"], + "worker-postmessage": ["worker", "postmessage"], + "context-2d-settransform": ["context-2d", "settransform"] + }[containerid] || [containerid, id]; } @@ -205,14 +205,14 @@ function preProcessHTML() { "navigator": "Navigator" }; const fullId = containerid + "-" + id; - if (exceptions[fullId]|| fromIdToIdl(containerid)) { + if (exceptions[fullId] || fromIdToIdl(containerid)) { let names = (exceptions[fullId] ? exceptions[fullId] : fromIdToIdl(containerid)).split(","); interfaces = idlInterfaces.filter(i => names.includes(i.name)); } if (Object.keys(mixins).includes(containerid)) { - // some container id are split across several mixins, lets find out which + // some container ids are split across several mixins, let's find out which const candidateInterfaceNames = [mixins[containerid]].concat(idlTree.filter(inc => inc.type === "includes" && inc.target === mixins[containerid]).map(inc => inc.includes)); - interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); + interfaces = candidateInterfaceNames.map(name => idlInterfaces.filter(iface => iface.name === name)).flat().filter(iface => iface && iface.members && iface.members.find(member => member.name.toLowerCase() === id)); } if (interfaces.length) { @@ -222,7 +222,7 @@ function preProcessHTML() { type = "dfn"; // dom-head-profile, intentionally omitted from IDL fragment if (id === "profile" && containerid === "head") { - return {type: "attribute", _for:"HTMLHeadElement"}; + return {type: "attribute", _for: "HTMLHeadElement"}; } relevantInterfaces = interfaces.filter(iface => iface.members.find(member => member.name && member.name.toLowerCase() === id)); if (relevantInterfaces.length) { @@ -233,14 +233,16 @@ function preProcessHTML() { return {type, _for: [... new Set(relevantInterfaces.map(iface => iface.name))].join(",")}; } - const enumName = id => { switch(id) { + const enumName = id => { + switch(id) { case "context-2d-direction": return "CanvasDirection"; case "context-2d-fillrule": return "CanvasFillRule"; case "context-2d-imagesmoothingquality": return "ImageSmoothingQuality"; case "context-2d-textalign": return "CanvasTextAlign"; case "context-2d-textbaseline": return "CanvasTextBaseline"; - } - }; + } + }; + let _enum = idlTree.find(i => i.type === "enum" && (i.name.toLowerCase() === containerid || enumName(containerid) === i.name)); // TODO check the value is defined if (_enum) return {type: "enum-value", _for: _enum.name}; @@ -252,19 +254,19 @@ function preProcessHTML() { // Ideally, get this fixed upstream switch(containerid) { // not an enum, but a well-defined DOMString - case "datatransfer-dropeffect": return {type: "dfn", _for:"DataTransfer.dropEffect"}; + case "datatransfer-dropeffect": return {type: "dfn", _for: "DataTransfer.dropEffect"}; // not an enum, but a well-defined DOMString - case "datatransfer-effectallowed": return {type: "dfn", _for:"DataTransfer.effectAllowed"}; - case "document-nameditem": return {type: "dfn", _for:"Document"}; + case "datatransfer-effectallowed": return {type: "dfn", _for: "DataTransfer.effectAllowed"}; + case "document-nameditem": return {type: "dfn", _for: "Document"}; // mode of the value attribute of the inputelement case "input-value": case "input-value-default": - return {type: "dfn", _for:"HTMLInputElement.value"}; + return {type: "dfn", _for: "HTMLInputElement.value"}; // not an enum, but a well-defined DOMString - case "texttrack-kind": return {type: "dfn", _for:"TextTrack.kind"}; + case "texttrack-kind": return {type: "dfn", _for: "TextTrack.kind"}; // dom-tree-accessors - case "tree": return { type:"dfn", _for:""}; - case "window-nameditem": return {type: "dfn", _for:"Window"}; + case "tree": return { type:"dfn", _for: ""}; + case "window-nameditem": return {type: "dfn", _for: "Window"}; } //throw "Cannot match " + containerid + " to a known IDL name (" + id + ")"; @@ -393,7 +395,16 @@ function preProcessHTML() { } if ((m = el.id.match(/^dom-([^-]+)$/) || el.id.match(/^dom-([^-]+)-[0-9]+$/) || el.id.match(/^dom-([^-]+)-constructor$/))) { - const globalscopes = ["Window", "WindowOrWorkerGlobalScope", "HTMLElement", "WindowSessionStorage", "WorkerGlobalScope", "WindowLocalStorage", "HTMLOrSVGElement", "ElementContentEditable"] + const globalscopes = [ + "ElementContentEditable", + "HTMLElement", + "HTMLOrSVGElement", + "Window", + "WindowLocalStorage", + "WindowOrWorkerGlobalScope", + "WindowSessionStorage", + "WorkerGlobalScope" + ]; const name = el.textContent.split('(')[0]; if (el.textContent.match(/\(/)) { // e.g. print(), Audio(src) diff --git a/tests/extract-dfns.js b/tests/extract-dfns.js index d00c83cb..f8734c05 100644 --- a/tests/extract-dfns.js +++ b/tests/extract-dfns.js @@ -92,7 +92,7 @@ const tests = [ html: "

    Foo

    ", changesToBaseDfn: [] }, - {title: "includes data-lt in its list of linking text", + {title: "uses text in data-lt as linking text", html: "Foo", changesToBaseDfn: [{linkingText: ["foo", "bar"]}] }, From f47885cae464ebbf9e11ab26569c016f38303aea Mon Sep 17 00:00:00 2001 From: Dominique Hazael-Massieux Date: Tue, 30 Jun 2020 11:22:07 +0200 Subject: [PATCH 5/5] Remove script-import of webidl2, overtaken by module import per https://github.com/tidoust/reffy/pull/340#discussion_r447518471 --- src/lib/util.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/util.js b/src/lib/util.js index 5399b56d..c1ada916 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -297,11 +297,6 @@ async function processSpecification(spec, callback, args, counter) { path: path.resolve(__dirname, '../../builds/browser.js') }); - // Import WebIDL2, needed to parse definitions out of the HTML spec - await page.addScriptTag({ - path: path.resolve(__dirname, '../../node_modules/webidl2/dist/webidl2.js') - }); - // Run the callback method in the browser context const results = await page.evaluate(callback, ...args);