reffy 20.0.9 → 20.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy",
3
- "version": "20.0.9",
3
+ "version": "20.0.11",
4
4
  "description": "W3C/WHATWG spec dependencies exploration companion. Features a short set of tools to study spec references as well as WebIDL term definitions and references found in W3C specifications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,19 +33,19 @@
33
33
  "main": "index.js",
34
34
  "bin": "./reffy.js",
35
35
  "dependencies": {
36
- "ajv": "8.17.1",
36
+ "ajv": "8.18.0",
37
37
  "ajv-formats": "3.0.1",
38
- "commander": "14.0.2",
38
+ "commander": "14.0.3",
39
39
  "fetch-filecache-for-crawling": "5.1.1",
40
- "puppeteer": "24.36.0",
40
+ "puppeteer": "24.37.3",
41
41
  "semver": "^7.3.5",
42
- "web-specs": "3.77.0",
42
+ "web-specs": "3.79.0",
43
43
  "webidl2": "24.5.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "respec": "35.6.1",
47
47
  "respec-hljs": "2.1.1",
48
- "rollup": "4.57.0",
48
+ "rollup": "4.57.1",
49
49
  "undici": "^7.0.0"
50
50
  },
51
51
  "overrides": {
@@ -1,439 +1,489 @@
1
- import informativeSelector from './informative-selector.mjs';
2
- import extractWebIdl from './extract-webidl.mjs';
3
- import {parse} from "../../node_modules/webidl2/index.js";
4
- import getAbsoluteUrl from './get-absolute-url.mjs';
5
-
6
- const singlePage = !document.querySelector('[data-reffy-page]');
7
- const href = el => el?.getAttribute("id") ? getAbsoluteUrl(el, {singlePage}) : null;
8
-
9
-
10
- export default function (spec) {
11
- // Used to find eventhandler attributes
12
- let idlInterfaces = [];
13
- try {
14
- const idl = extractWebIdl();
15
- const idlTree = parse(idl);
16
- idlInterfaces = idlTree.filter(item =>
17
- item.type === "interface" ||
18
- item.type === "interface mixin");
19
- }
20
- catch {
21
- // Spec defines some invalid Web IDL, proceed without it
22
- }
23
-
24
- // associate event names from event handlers to interfaces with such an handler
25
- const handledEventNames = idlInterfaces
26
- .map(iface => iface.members
27
- .filter(m => m.idlType?.idlType === "EventHandler" && m.type === "attribute" && m.name?.startsWith("on"))
28
- .map(m => [m.name.slice(2), iface.name]))
29
- .flat()
30
- .reduce((acc, b) => {
31
- if (!acc[b[0]]) acc[b[0]] = [];
32
- acc[b[0]].push(b[1]);
33
- return acc;
34
- }, {});
35
-
36
- function isSameEvent(e1, e2) {
37
- const res = e1.type === e2.type &&
38
- ((e1.href && e1.href === e2.href ) ||
39
- (e1.targets?.sort()?.join("|") === e2.targets?.sort()?.join("|")));
40
- if (res && e1.cancelable !== undefined && e2.cancelable !== undefined && e1.cancelable !== e2.cancelable) {
41
- console.error(`[reffy] Found two occurrences of same event with different "cancelable" properties in ${spec.title}: type=${e1.type} targets=${e1.targets.join(', ')} href=${e1.href}`);
42
- }
43
- return res;
44
- }
45
-
46
- function fromEventElementToTargetInterfaces(eventEl) {
47
- if (!eventEl) return;
48
-
49
- if (eventEl.dataset?.dfnFor || eventEl.dataset?.linkFor) {
50
- return (eventEl.dataset.dfnFor || eventEl.dataset.linkFor).split(",").map(t => t.trim());
51
- } else if (eventEl.getAttribute("href")?.startsWith("#")) {
52
- const dfn = document.getElementById(eventEl.getAttribute("href").slice(1));
53
- if (dfn && dfn.dataset?.dfnFor) {
54
- return dfn.dataset.dfnFor.split(",").map(t => t.trim());
55
- }
56
- } else if (handledEventNames[eventEl.textContent]?.length) {
57
- // Search for on<event> EventHandler in IDL
58
- const matchingInterfaces = handledEventNames[eventEl.textContent];
59
- if (matchingInterfaces.length === 1) {
60
- // only one such handler, we assume it's a match
61
- return matchingInterfaces;
62
- } else {
63
- console.error(`[reffy] Multiple event handler named ${eventEl.textContent}, cannot associate reliably to an interface in ${spec.title}`);
64
- }
65
- }
66
- }
67
-
68
-
69
- let events = [];
70
- // Look for event summary tables
71
- // ignore DOM spec which uses a matching table format
72
- // to map to legacy event types
73
- let hasStructuredData = false;
74
- if (spec.shortname !== "dom") {
75
- document.querySelectorAll("table").forEach(table => {
76
- const firstHeading = table.querySelector("thead tr th")?.textContent?.trim();
77
- if (firstHeading?.match(/^Event/) && firstHeading !== "Event handler") {
78
- hasStructuredData = true;
79
- // Useful e.g. for pointerevents
80
- const bubblingInfoColumn = [...table.querySelectorAll("thead th")]
81
- .findIndex(n => n.textContent.trim().match(/^bubbl/i));
82
- const cancelableInfoColumn = [...table.querySelectorAll("thead th")]
83
- .findIndex(n => n.textContent.trim().match(/^cancel/i));
84
- const interfaceColumn = [...table.querySelectorAll("thead th")]
85
- .findIndex(n => n.textContent.trim().match(/^(dom )?interface/i));
86
- const targetsColumn = [...table.querySelectorAll("thead th")]
87
- .findIndex(n => n.textContent.trim().match(/target/i));
88
-
89
- table.querySelectorAll("tbody tr").forEach(tr => {
90
- const event = {};
91
- // clean up possible MDN annotations
92
- // but keeping the original to swap it back in after processing
93
- // to leave the DOM intact for other processing scripts
94
- // (we need the clean up node in-tree to compute the proper href)
95
- const origEventEl = tr.querySelector("*:first-child");
96
- const eventEl = origEventEl.cloneNode(true);
97
- origEventEl.replaceWith(eventEl);
98
- const annotations = eventEl.querySelectorAll("aside, .mdn-anno");
99
- annotations.forEach(n => n.remove());
100
-
101
- let el = eventEl.querySelector("dfn,a");
102
- if (!el) {
103
- el = eventEl.querySelector("code");
104
- }
105
- if (!el) {
106
- eventEl.replaceWith(origEventEl);
107
- return;
108
- }
109
- if (el.tagName === "DFN" && el.id) {
110
- event.href = href(el);
111
- } else if (el.tagName === "A") {
112
- if (!el.getAttribute("href").startsWith("https://")) {
113
- const url = new URL(el.href);
114
- event.href = href(document.getElementById(url.hash.slice(1)));
115
- } else {
116
- event.href = el.href;
117
- }
118
- }
119
- event.src = { format: "summary table", href: href(el.closest('*[id]')) };
120
- event.type = eventEl.textContent.trim();
121
- event.targets = fromEventElementToTargetInterfaces(eventEl.querySelector("dfn,a[href^='#']"));
122
- if (bubblingInfoColumn >= 0) {
123
- event.bubbles = tr.querySelector(`td:nth-child(${bubblingInfoColumn + 1})`)?.textContent?.trim() === "Yes";
124
- }
125
- if (cancelableInfoColumn >= 0) {
126
- event.cancelable = !!tr.querySelector(`td:nth-child(${cancelableInfoColumn + 1})`)?.textContent?.trim().match(/(yes)|✓|(varies)/i);
127
- }
128
- if (interfaceColumn >= 0) {
129
- event.interface =
130
- tr.querySelector(`td:nth-child(${interfaceColumn + 1}) a`)?.textContent ??
131
- tr.querySelector(`td:nth-child(${interfaceColumn + 1}) code`)?.textContent;
132
- }
133
- if (targetsColumn >= 0 && !event.targets) {
134
- event.targets = tr.querySelector(`td:nth-child(${targetsColumn + 1})`)?.textContent?.split(',').map(t => t.trim());
135
- }
136
- events.push(event);
137
- eventEl.replaceWith(origEventEl);
138
- });
139
- } else if (table.className === "def") {
140
- // Used in https://drafts.csswg.org/css-nav-1/
141
- const rowHeadings = [...table.querySelectorAll("tbody th")];
142
- if (!rowHeadings.find(th => th.textContent.trim() === "Bubbles")) {
143
- return;
144
- }
145
- const eventTypeRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim().match(/^type/i));
146
- const bubblingInfoRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim() === "Bubbles");
147
- const cancelableInfoRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim() === "Cancelable");
148
- const interfaceRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim().match(/^interface/i));
149
- const eventName = table.querySelector(`tr:nth-child(${eventTypeRow + 1}) td:nth-child(2)`)?.textContent?.trim();
150
- const bubblesCell = table.querySelector(`tr:nth-child(${bubblingInfoRow + 1}) td:nth-child(2)`);
151
- const bubbles = bubblesCell ? bubblesCell.textContent.trim() === "Yes" : null;
152
- const cancelableCell = table.querySelector(`tr:nth-child(${cancelableInfoRow + 1}) td:nth-child(2)`);
153
- const cancelable = cancelableCell ? cancelableCell.textContent.trim() === "Yes" : null;
154
- const iface = table.querySelector(`tr:nth-child(${interfaceRow + 1}) td:nth-child(2)`)?.textContent?.trim();
155
- if (eventName) {
156
- events.push({
157
- type: eventName, interface: iface, bubbles, cancelable,
158
- src: { format: "css definition table", href: href(table.closest('*[id]')) },
159
- href: href(table.closest('*[id]')) });
160
- }
161
- }
162
- });
163
- }
164
- // Look for the DOM-suggested sentence "Fire an event named X"
165
- // or the Service Worker extension of "fire (a) functional event named"
166
- const isFiringLink = a => a.href === "https://dom.spec.whatwg.org/#concept-event-fire" ||
167
- a.href === "https://w3c.github.io/ServiceWorker/#fire-functional-event" ||
168
- a.href === "https://www.w3.org/TR/service-workers-1/#fire-functional-event-algorithm" ||
169
- a.href === "https://www.w3.org/TR/service-workers-1/#fire-functional-event" ||
170
- a.href === "https://www.w3.org/TR/service-workers/#fire-functional-event-algorithm" ||
171
- a.href === "https://www.w3.org/TR/service-workers/#fire-functional-event" ||
172
- a.href === "https://w3c.github.io/pointerevents/#dfn-fire-a-pointer-event";
173
- [...document.querySelectorAll("a")]
174
- .filter(a => !a.closest(informativeSelector) && isFiringLink(a))
175
- .forEach(a => {
176
- // Clone and drop possible annotations to avoid extracting asides.
177
- // (note the need to temporarily add the cloned node to the document
178
- // so that ranges can be used)
179
- const apos = [...a.parentNode.children].findIndex(c => c === a);
180
- const container = a.parentNode.cloneNode(true);
181
- const aclone = container.children[apos];
182
-
183
- const annotations = container.querySelectorAll("aside, .mdn-anno");
184
- annotations.forEach(n => n.remove());
185
- document.body.appendChild(container);
186
-
187
-
188
- // There can be multiple "fire an event" links in a container,
189
- // limiting our text parsing to content in between two such links,
190
- // or to the first time aside appears (no whitespaces in Bikeshed
191
- // so code would extract the beginning of the annotation otherwise),
192
- // or the end of the container if neither of the above occurs.
193
- const range = document.createRange();
194
- range.selectNode(container);
195
- range.setStart(aclone, 0);
196
-
197
- let nextFiringEl, curEl = aclone;
198
- while ((curEl = curEl.nextElementSibling)) {
199
- if (curEl.tagName === "A" && isFiringLink(curEl)) {
200
- nextFiringEl = curEl;
201
- break;
202
- }
203
- }
204
-
205
- if (nextFiringEl) {
206
- range.setEndBefore(nextFiringEl);
207
- }
208
- const parsedText = range.toString();
209
- document.body.removeChild(container);
210
- let phrasing;
211
- let m = parsedText.match(/fir(e|ing)\s+a(n|\s+pointer)\s+event\s+named\s+"?(?<eventName>[a-z]+)/i);
212
- if (m) {
213
- if (m[2] === "n") {
214
- phrasing = "fire an event";
215
- } else {
216
- phrasing = "fire a pointer event";
217
- }
218
- } else {
219
- m = parsedText.match(/fir(e|ing)\sa?\s*functional\s+event\s+((named|given)\s+)?"?(?<eventName>[a-z]+)/i);
220
- if (m) {
221
- phrasing = "fire functional event";
222
- }
223
- }
224
-
225
- if (phrasing) {
226
- const name = m.groups.eventName;
227
- let newEvent = true;
228
- let event = {
229
- src: { format: "fire an event phrasing", href: href(a.closest('*[id]')) },
230
- href: href(a.closest('*[id]'))
231
- };
232
- // this matches "fire an event named eventName" in battery-status and
233
- // media capture main, named type in fullscreen, named e, event in html
234
- // name in notifications API
235
- if (name === 'eventName' || name === 'type' || name === 'e' || name === 'event' || name === 'name') {
236
- return;
237
- } else {
238
- event.type = name;
239
- // looking at the element following the link
240
- // if its content match the name of the event
241
- const eventEl = a.nextElementSibling?.textContent?.trim() === event.type ? a.nextElementSibling.querySelector("a,dfn") || a.nextElementSibling : null;
242
- if (eventEl) {
243
- if (eventEl.tagName === "A" && eventEl.getAttribute("href")) {
244
- // use the target of the link as our href
245
- event.href = eventEl.href;
246
- } else if (eventEl.tagName === "DFN" && eventEl.id) {
247
- event.href = href(eventEl);
248
- }
249
- event.targets = fromEventElementToTargetInterfaces(eventEl);
250
- }
251
- // if we have already detected this combination, skip it
252
- if (events.find(e => isSameEvent(event, e))) {
253
- newEvent = false;
254
- event = events.find(e => isSameEvent(event, e));
255
- }
256
- }
257
- if (!event.interface) {
258
- let curEl = aclone, iface;
259
- while ((curEl = curEl.nextElementSibling) && curEl !== nextFiringEl) {
260
- if (curEl.textContent.match(/^([A-Z]+[a-z0-9]*)+Event$/)) {
261
- iface = curEl.textContent.trim();
262
- break;
263
- }
264
- }
265
- if (iface) {
266
- event.interface = iface;
267
- } else {
268
- // Fire an event ⇒ Event interface
269
- if (phrasing === "fire an event") {
270
- event.interface = "Event";
271
- } else if (phrasing === "fire a pointer event") {
272
- // Fire a pointerevent ⇒ PointerEvent interface
273
- event.interface = "PointerEvent";
274
- } else {
275
- // Functional event ⇒ Extendable interface
276
- event.interface = "ExtendableEvent";
277
- }
278
- }
279
- }
280
- if (event.bubbles === undefined && event.cancelable === undefined) {
281
- if (parsedText.match(/bubbles and cancelable attributes/)) {
282
- if (parsedText.match(/true/)) {
283
- event.bubbles = true;
284
- event.cancelable = true;
285
- } else if (parsedText.match(/false/)) {
286
- event.bubbles = false;
287
- event.cancelable = false;
288
- }
289
- }
290
- }
291
- if (event.bubbles === undefined) {
292
- if (parsedText.match(/bubbles attribute/)) {
293
- if (parsedText.match(/true/)) {
294
- event.bubbles = true;
295
- } else if (parsedText.match(/false/)) {
296
- event.bubbles = false;
297
- }
298
- } else if (parsedText.match(/bubbles/) || parsedText.match(/bubbling/)) {
299
- event.bubbles = true;
300
- } else if (parsedText.match(/not bubble/)) {
301
- event.bubbles = false;
302
- }
303
- }
304
- if (event.cancelable === undefined) {
305
- if (parsedText.match(/cancelable attribute/)) {
306
- if (parsedText.match(/true/)) {
307
- event.cancelable = true;
308
- } else if (parsedText.match(/false/)) {
309
- event.cancelable = false;
310
- }
311
- } else if (parsedText.match(/not cancelable/) || parsedText.match(/not be cancelable/)) {
312
- event.cancelable = false;
313
- } else if (parsedText.match(/cancelable/)) {
314
- event.cancelable = true;
315
- }
316
- }
317
- if (newEvent) {
318
- events.push(event);
319
- }
320
- }
321
- });
322
-
323
- // find events via IDL on<event> attributes with type EventHandler
324
- for (let eventName of Object.keys(handledEventNames)) {
325
- const matchingEvents = events.filter(e => e.type === eventName);
326
- if (matchingEvents.length === 0 && !hasStructuredData) {
327
- // We have not encountered such an event so far
328
- for (let iface of handledEventNames[eventName]) {
329
- events.push({
330
- type: eventName, targets: [iface], interface: null,
331
- src: { format: "IDL eventHandler", href: href(document.body) } }); // FIXME: find id of the IDL fragment
332
- }
333
- } else if (matchingEvents.length === 1) {
334
- // A single matching event, we assume all event handlers relate to it
335
- const [matchingEvent] = matchingEvents;
336
- // assign all interfaces at once if none is set
337
- // but don't add to existing interfaces otherwise
338
- if (!matchingEvent.targets) {
339
- matchingEvent.targets = handledEventNames[eventName];
340
- } else if (!hasStructuredData) {
341
- const missingIface = handledEventNames[eventName]
342
- .find(iface => !matchingEvent.targets.includes(iface));
343
- if (missingIface) {
344
- console.warn(`[reffy] More event handlers matching name ${eventName}, e.g. on ${missingIface} than ones identified in spec definitions`);
345
- }
346
- }
347
- } else {
348
- // More than one event with that name
349
- // we can only check if this matches known information
350
- // to warn of the gap otherwise
351
- for (let iface of handledEventNames[eventName]) {
352
- if (!matchingEvents.find(e => e.targets?.includes(iface))) {
353
- console.warn(`[reffy] Could not determine which event named ${eventName} match EventHandler of ${iface} interface in ${spec.title}`);
354
- }
355
- }
356
- }
357
- }
358
-
359
- // Find definitions marked as of event type
360
- [...document.querySelectorAll('dfn[data-dfn-type="event"')].forEach(dfn => {
361
- const type = dfn.textContent.trim();
362
- const container = dfn.parentNode;
363
- const event = {
364
- type, interface: null, targets: fromEventElementToTargetInterfaces(dfn),
365
- src: { format: "dfn", href: href(dfn.closest("*[id]")) },
366
- href: href(dfn)
367
- };
368
- // CSS Animations & Transitions uses dt/dd to describe events
369
- // and uses a ul in the dd to describe bubbling behavior
370
- let bubbles, iface, cancelable;
371
- if (container.tagName === "DT") {
372
- const bubbleItem = [...container.nextElementSibling.querySelectorAll("li")]
373
- .find(li => li.textContent.startsWith("Bubbles:"));
374
- if (bubbleItem) {
375
- bubbles = !!bubbleItem.textContent.match(/yes/i);
376
- }
377
- const cancelableItem = [...container.nextElementSibling.querySelectorAll("li")]
378
- .find(li => li.textContent.startsWith("Cancelable:"));
379
- if (cancelableItem) {
380
- cancelable = !!cancelableItem.textContent.match(/yes/i);
381
- }
382
- // CSS Animation & Transitions document the event in the heading
383
- // of the section where the definitions are located
384
- let currentEl = container.parentNode;
385
- while(currentEl) {
386
- if (currentEl.tagName.match(/^H[1-6]$/)) {
387
- break;
388
- }
389
- currentEl = currentEl.previousElementSibling;
390
- }
391
- const interfaceEl = currentEl?.querySelector("code");
392
- if (interfaceEl?.textContent?.match(/^[A-Z][a-z]+Event$/)) {
393
- iface = interfaceEl.textContent;
394
- }
395
- }
396
- const ev = events.find(e => isSameEvent(event, e));
397
- if (!ev) {
398
- if (iface) {
399
- event.interface = iface;
400
- }
401
- event.bubbles = bubbles;
402
- event.cancelable = cancelable;
403
- events.push(event);
404
- if (!iface) {
405
- console.error(`[reffy] No interface hint found for event definition ${event.type} in ${spec.title}`);
406
- }
407
- } else {
408
- if (iface) {
409
- ev.interface = iface;
410
- }
411
- if (!ev.href && event.href) {
412
- ev.href = event.href;
413
- }
414
- if (bubbles !== undefined) {
415
- ev.bubbles = bubbles;
416
- }
417
- if (cancelable !== undefined) {
418
- ev.cancelable = cancelable;
419
- }
420
- }
421
- });
422
- return events
423
- .map(e => {
424
- // Drop null properties (mandated by the schema for event extracts)
425
- if (e.hasOwnProperty('interface') && !e.interface) {
426
- delete e.interface;
427
- }
428
- if (e.hasOwnProperty('href') && !e.href) {
429
- delete e.href;
430
- }
431
- if (e.src && e.src.hasOwnProperty('href') && !e.src.href) {
432
- delete e.src.href;
433
- }
434
- return e;
435
- })
436
- .map(e => e.href && !e.href.startsWith(window.location.toString()) ?
437
- Object.assign(e, {isExtension: true}) :
438
- e) ;
439
- }
1
+ import informativeSelector from './informative-selector.mjs';
2
+ import extractWebIdl from './extract-webidl.mjs';
3
+ import {parse} from "../../node_modules/webidl2/index.js";
4
+ import getAbsoluteUrl from './get-absolute-url.mjs';
5
+
6
+ const singlePage = !document.querySelector('[data-reffy-page]');
7
+ const href = el => el?.getAttribute("id") ? getAbsoluteUrl(el, {singlePage}) : null;
8
+
9
+
10
+ export default function (spec) {
11
+ // Used to find eventhandler attributes
12
+ let idlInterfaces = [];
13
+ try {
14
+ const idl = extractWebIdl();
15
+ const idlTree = parse(idl);
16
+ idlInterfaces = idlTree.filter(item =>
17
+ item.type === "interface" ||
18
+ item.type === "interface mixin");
19
+ }
20
+ catch {
21
+ // Spec defines some invalid Web IDL, proceed without it
22
+ }
23
+
24
+ // associate event names from event handlers to interfaces with such an handler
25
+ const handledEventNames = idlInterfaces
26
+ .map(iface => iface.members
27
+ .filter(m => m.idlType?.idlType === "EventHandler" && m.type === "attribute" && m.name?.startsWith("on"))
28
+ .map(m => [m.name.slice(2), iface.name]))
29
+ .flat()
30
+ .reduce((acc, b) => {
31
+ if (!acc[b[0]]) acc[b[0]] = [];
32
+ acc[b[0]].push(b[1]);
33
+ return acc;
34
+ }, {});
35
+
36
+ // Return true if the second event object describes the same event as the
37
+ // first one. Note event types defined in event tables typically complete
38
+ // event definitions for which we don't have any target information.
39
+ function isSameEvent(e1, e2) {
40
+ const res = e1.type === e2.type &&
41
+ ((e1.href && e1.href === e2.href ) ||
42
+ (e1.targets?.sort()?.join("|") === e2.targets?.sort()?.join("|")) ||
43
+ (e2.src.format === 'event table'));
44
+ if (res && e1.cancelable !== undefined && e2.cancelable !== undefined && e1.cancelable !== e2.cancelable) {
45
+ console.error(`[reffy] Found two occurrences of same event with different "cancelable" properties in ${spec.title}: type=${e1.type} targets=${e1.targets.join(', ')} href=${e1.href}`);
46
+ }
47
+ return res;
48
+ }
49
+
50
+ function fromEventElementToTargetInterfaces(eventEl) {
51
+ if (!eventEl) return;
52
+
53
+ if (eventEl.dataset?.dfnFor || eventEl.dataset?.linkFor) {
54
+ return (eventEl.dataset.dfnFor || eventEl.dataset.linkFor).split(",").map(t => t.trim());
55
+ } else if (eventEl.getAttribute("href")?.startsWith("#")) {
56
+ const dfn = document.getElementById(eventEl.getAttribute("href").slice(1));
57
+ if (dfn && dfn.dataset?.dfnFor) {
58
+ return dfn.dataset.dfnFor.split(",").map(t => t.trim());
59
+ }
60
+ } else if (handledEventNames[eventEl.textContent]?.length) {
61
+ // Search for on<event> EventHandler in IDL
62
+ const matchingInterfaces = handledEventNames[eventEl.textContent];
63
+ if (matchingInterfaces.length === 1) {
64
+ // only one such handler, we assume it's a match
65
+ return matchingInterfaces;
66
+ } else {
67
+ console.error(`[reffy] Multiple event handler named ${eventEl.textContent}, cannot associate reliably to an interface in ${spec.title}`);
68
+ }
69
+ }
70
+ }
71
+
72
+
73
+ let events = [];
74
+
75
+ // Look for event summary tables
76
+ // ignore DOM spec which uses a matching table format
77
+ // to map to legacy event types
78
+ let hasStructuredData = false;
79
+ if (spec.shortname !== "dom") {
80
+ document.querySelectorAll("table").forEach(table => {
81
+ const firstHeading = table.querySelector("thead tr th")?.textContent?.trim();
82
+ if (firstHeading?.match(/^Event/) && firstHeading !== "Event handler") {
83
+ hasStructuredData = true;
84
+ // Useful e.g. for pointerevents
85
+ const bubblingInfoColumn = [...table.querySelectorAll("thead th")]
86
+ .findIndex(n => n.textContent.trim().match(/^bubbl/i));
87
+ const cancelableInfoColumn = [...table.querySelectorAll("thead th")]
88
+ .findIndex(n => n.textContent.trim().match(/^cancel/i));
89
+ const interfaceColumn = [...table.querySelectorAll("thead th")]
90
+ .findIndex(n => n.textContent.trim().match(/^(dom )?interface/i));
91
+ const targetsColumn = [...table.querySelectorAll("thead th")]
92
+ .findIndex(n => n.textContent.trim().match(/target/i));
93
+
94
+ table.querySelectorAll("tbody tr").forEach(tr => {
95
+ const event = {};
96
+ // clean up possible MDN annotations
97
+ // but keeping the original to swap it back in after processing
98
+ // to leave the DOM intact for other processing scripts
99
+ // (we need the clean up node in-tree to compute the proper href)
100
+ const origEventEl = tr.querySelector("*:first-child");
101
+ const eventEl = origEventEl.cloneNode(true);
102
+ origEventEl.replaceWith(eventEl);
103
+ const annotations = eventEl.querySelectorAll("aside, .mdn-anno");
104
+ annotations.forEach(n => n.remove());
105
+
106
+ let el = eventEl.querySelector("dfn,a");
107
+ if (!el) {
108
+ el = eventEl.querySelector("code");
109
+ }
110
+ if (!el) {
111
+ eventEl.replaceWith(origEventEl);
112
+ return;
113
+ }
114
+ if (el.tagName === "DFN" && el.id) {
115
+ event.href = href(el);
116
+ } else if (el.tagName === "A") {
117
+ if (!el.getAttribute("href").startsWith("https://")) {
118
+ const url = new URL(el.href);
119
+ event.href = href(document.getElementById(url.hash.slice(1)));
120
+ } else {
121
+ event.href = el.href;
122
+ }
123
+ }
124
+ event.src = { format: "summary table", href: href(el.closest('*[id]')) };
125
+ event.type = eventEl.textContent.trim();
126
+ event.targets = fromEventElementToTargetInterfaces(eventEl.querySelector("dfn,a[href^='#']"));
127
+ if (bubblingInfoColumn >= 0) {
128
+ event.bubbles = tr.querySelector(`td:nth-child(${bubblingInfoColumn + 1})`)?.textContent?.trim() === "Yes";
129
+ }
130
+ if (cancelableInfoColumn >= 0) {
131
+ event.cancelable = !!tr.querySelector(`td:nth-child(${cancelableInfoColumn + 1})`)?.textContent?.trim().match(/(yes)|✓|(varies)/i);
132
+ }
133
+ if (interfaceColumn >= 0) {
134
+ event.interface =
135
+ tr.querySelector(`td:nth-child(${interfaceColumn + 1}) a`)?.textContent ??
136
+ tr.querySelector(`td:nth-child(${interfaceColumn + 1}) code`)?.textContent;
137
+ }
138
+ if (targetsColumn >= 0 && !event.targets) {
139
+ event.targets = tr.querySelector(`td:nth-child(${targetsColumn + 1})`)?.textContent?.split(',').map(t => t.trim());
140
+ }
141
+ events.push(event);
142
+ eventEl.replaceWith(origEventEl);
143
+ });
144
+ } else if (table.className === "def") {
145
+ // Used in https://drafts.csswg.org/css-nav-1/
146
+ const rowHeadings = [...table.querySelectorAll("tbody th")];
147
+ if (!rowHeadings.find(th => th.textContent.trim() === "Bubbles")) {
148
+ return;
149
+ }
150
+ const eventTypeRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim().match(/^type/i));
151
+ const bubblingInfoRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim() === "Bubbles");
152
+ const cancelableInfoRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim() === "Cancelable");
153
+ const interfaceRow = [...table.querySelectorAll("tbody th")].findIndex(n => n.textContent.trim().match(/^interface/i));
154
+ const eventName = table.querySelector(`tr:nth-child(${eventTypeRow + 1}) td:nth-child(2)`)?.textContent?.trim();
155
+ const bubblesCell = table.querySelector(`tr:nth-child(${bubblingInfoRow + 1}) td:nth-child(2)`);
156
+ const bubbles = bubblesCell ? bubblesCell.textContent.trim() === "Yes" : null;
157
+ const cancelableCell = table.querySelector(`tr:nth-child(${cancelableInfoRow + 1}) td:nth-child(2)`);
158
+ const cancelable = cancelableCell ? cancelableCell.textContent.trim() === "Yes" : null;
159
+ const iface = table.querySelector(`tr:nth-child(${interfaceRow + 1}) td:nth-child(2)`)?.textContent?.trim();
160
+ if (eventName) {
161
+ events.push({
162
+ type: eventName, interface: iface, bubbles, cancelable,
163
+ src: { format: "css definition table", href: href(table.closest('*[id]')) },
164
+ href: href(table.closest('*[id]')) });
165
+ }
166
+ }
167
+ });
168
+ }
169
+
170
+ // Look for definitions in event-definition tables
171
+ // (used in Pointer Events and UI Events)
172
+ [...document.querySelectorAll('table.event-definition')].forEach(table => {
173
+ const properties = [...table.querySelectorAll('tr')]
174
+ .map(line => {
175
+ const nameEl = line.querySelector('th');
176
+ const valueEl = line.querySelector('td');
177
+ if (!nameEl || !valueEl) {
178
+ return null;
179
+ }
180
+ let name = nameEl.textContent.trim().toLowerCase();
181
+ let value = valueEl.textContent.trim();
182
+ if (name === 'trusted targets') {
183
+ name = 'targets';
184
+ value = value.split(',').map(v => v.trim());
185
+ }
186
+ if (['type', 'interface', 'targets'].includes(name)) {
187
+ return { name, value };
188
+ }
189
+ else if (['bubbles', 'cancelable'].includes(name)) {
190
+ return { name, value: value.toLowerCase() === 'yes' ? true : false };
191
+ }
192
+ else {
193
+ return null;
194
+ }
195
+ })
196
+ .filter(prop => !!prop);
197
+ const event = {};
198
+ for (const prop of properties) {
199
+ event[prop.name] = prop.value;
200
+ }
201
+ event.src = {
202
+ format: 'event table',
203
+ href: href(table.closest('*[id]'))
204
+ };
205
+ // Prefer summary table to definition in an event table if both exist
206
+ // because the latter may include prose around the interface and target
207
+ // names that make it harder to extract meaningful values.
208
+ if (!events.find(e => isSameEvent(e, event))) {
209
+ events.push(event);
210
+ }
211
+ });
212
+
213
+ // Look for the DOM-suggested sentence "Fire an event named X"
214
+ // or the Service Worker extension of "fire (a) functional event named"
215
+ const isFiringLink = a => a.href === "https://dom.spec.whatwg.org/#concept-event-fire" ||
216
+ a.href === "https://w3c.github.io/ServiceWorker/#fire-functional-event" ||
217
+ a.href === "https://www.w3.org/TR/service-workers-1/#fire-functional-event-algorithm" ||
218
+ a.href === "https://www.w3.org/TR/service-workers-1/#fire-functional-event" ||
219
+ a.href === "https://www.w3.org/TR/service-workers/#fire-functional-event-algorithm" ||
220
+ a.href === "https://www.w3.org/TR/service-workers/#fire-functional-event" ||
221
+ a.href === "https://w3c.github.io/pointerevents/#dfn-fire-a-pointer-event";
222
+ [...document.querySelectorAll("a")]
223
+ .filter(a => !a.closest(informativeSelector) && isFiringLink(a))
224
+ .forEach(a => {
225
+ // Clone and drop possible annotations to avoid extracting asides.
226
+ // (note the need to temporarily add the cloned node to the document
227
+ // so that ranges can be used)
228
+ const apos = [...a.parentNode.children].findIndex(c => c === a);
229
+ const container = a.parentNode.cloneNode(true);
230
+ const aclone = container.children[apos];
231
+
232
+ const annotations = container.querySelectorAll("aside, .mdn-anno");
233
+ annotations.forEach(n => n.remove());
234
+ document.body.appendChild(container);
235
+
236
+
237
+ // There can be multiple "fire an event" links in a container,
238
+ // limiting our text parsing to content in between two such links,
239
+ // or to the first time aside appears (no whitespaces in Bikeshed
240
+ // so code would extract the beginning of the annotation otherwise),
241
+ // or the end of the container if neither of the above occurs.
242
+ const range = document.createRange();
243
+ range.selectNode(container);
244
+ range.setStart(aclone, 0);
245
+
246
+ let nextFiringEl, curEl = aclone;
247
+ while ((curEl = curEl.nextElementSibling)) {
248
+ if (curEl.tagName === "A" && isFiringLink(curEl)) {
249
+ nextFiringEl = curEl;
250
+ break;
251
+ }
252
+ }
253
+
254
+ if (nextFiringEl) {
255
+ range.setEndBefore(nextFiringEl);
256
+ }
257
+ const parsedText = range.toString();
258
+ document.body.removeChild(container);
259
+ let phrasing;
260
+ let m = parsedText.match(/fir(e|ing)\s+a(n|\s+pointer)\s+event\s+named\s+"?(?<eventName>[a-z]+)/i);
261
+ if (m) {
262
+ if (m[2] === "n") {
263
+ phrasing = "fire an event";
264
+ } else {
265
+ phrasing = "fire a pointer event";
266
+ }
267
+ } else {
268
+ m = parsedText.match(/fir(e|ing)\sa?\s*functional\s+event\s+((named|given)\s+)?"?(?<eventName>[a-z]+)/i);
269
+ if (m) {
270
+ phrasing = "fire functional event";
271
+ }
272
+ }
273
+
274
+ if (phrasing) {
275
+ const name = m.groups.eventName;
276
+ let newEvent = true;
277
+ let event = {
278
+ src: { format: "fire an event phrasing", href: href(a.closest('*[id]')) },
279
+ href: href(a.closest('*[id]'))
280
+ };
281
+ // this matches "fire an event named eventName" in battery-status and
282
+ // media capture main, named type in fullscreen, named e, event in html
283
+ // name in notifications API
284
+ if (name === 'eventName' || name === 'type' || name === 'e' || name === 'event' || name === 'name') {
285
+ return;
286
+ } else {
287
+ event.type = name;
288
+ // looking at the element following the link
289
+ // if its content match the name of the event
290
+ const eventEl = a.nextElementSibling?.textContent?.trim() === event.type ? a.nextElementSibling.querySelector("a,dfn") || a.nextElementSibling : null;
291
+ if (eventEl) {
292
+ if (eventEl.tagName === "A" && eventEl.getAttribute("href")) {
293
+ // use the target of the link as our href
294
+ event.href = eventEl.href;
295
+ } else if (eventEl.tagName === "DFN" && eventEl.id) {
296
+ event.href = href(eventEl);
297
+ }
298
+ event.targets = fromEventElementToTargetInterfaces(eventEl);
299
+ }
300
+ // if we have already detected this combination, skip it
301
+ if (events.find(e => isSameEvent(event, e))) {
302
+ newEvent = false;
303
+ event = events.find(e => isSameEvent(event, e));
304
+ }
305
+ }
306
+ if (!event.interface) {
307
+ let curEl = aclone, iface;
308
+ while ((curEl = curEl.nextElementSibling) && curEl !== nextFiringEl) {
309
+ if (curEl.textContent.match(/^([A-Z]+[a-z0-9]*)+Event$/)) {
310
+ iface = curEl.textContent.trim();
311
+ break;
312
+ }
313
+ }
314
+ if (iface) {
315
+ event.interface = iface;
316
+ } else {
317
+ // Fire an event ⇒ Event interface
318
+ if (phrasing === "fire an event") {
319
+ event.interface = "Event";
320
+ } else if (phrasing === "fire a pointer event") {
321
+ // Fire a pointerevent ⇒ PointerEvent interface
322
+ event.interface = "PointerEvent";
323
+ } else {
324
+ // Functional event Extendable interface
325
+ event.interface = "ExtendableEvent";
326
+ }
327
+ }
328
+ }
329
+ if (event.bubbles === undefined && event.cancelable === undefined) {
330
+ if (parsedText.match(/bubbles and cancelable attributes/)) {
331
+ if (parsedText.match(/true/)) {
332
+ event.bubbles = true;
333
+ event.cancelable = true;
334
+ } else if (parsedText.match(/false/)) {
335
+ event.bubbles = false;
336
+ event.cancelable = false;
337
+ }
338
+ }
339
+ }
340
+ if (event.bubbles === undefined) {
341
+ if (parsedText.match(/bubbles attribute/)) {
342
+ if (parsedText.match(/true/)) {
343
+ event.bubbles = true;
344
+ } else if (parsedText.match(/false/)) {
345
+ event.bubbles = false;
346
+ }
347
+ } else if (parsedText.match(/bubbles/) || parsedText.match(/bubbling/)) {
348
+ event.bubbles = true;
349
+ } else if (parsedText.match(/not bubble/)) {
350
+ event.bubbles = false;
351
+ }
352
+ }
353
+ if (event.cancelable === undefined) {
354
+ if (parsedText.match(/cancelable attribute/)) {
355
+ if (parsedText.match(/true/)) {
356
+ event.cancelable = true;
357
+ } else if (parsedText.match(/false/)) {
358
+ event.cancelable = false;
359
+ }
360
+ } else if (parsedText.match(/not cancelable/) || parsedText.match(/not be cancelable/)) {
361
+ event.cancelable = false;
362
+ } else if (parsedText.match(/cancelable/)) {
363
+ event.cancelable = true;
364
+ }
365
+ }
366
+ if (newEvent) {
367
+ events.push(event);
368
+ }
369
+ }
370
+ });
371
+
372
+ // find events via IDL on<event> attributes with type EventHandler
373
+ for (let eventName of Object.keys(handledEventNames)) {
374
+ const matchingEvents = events.filter(e => e.type === eventName);
375
+ if (matchingEvents.length === 0 && !hasStructuredData) {
376
+ // We have not encountered such an event so far
377
+ for (let iface of handledEventNames[eventName]) {
378
+ events.push({
379
+ type: eventName, targets: [iface], interface: null,
380
+ src: { format: "IDL eventHandler", href: href(document.body) } }); // FIXME: find id of the IDL fragment
381
+ }
382
+ } else if (matchingEvents.length === 1) {
383
+ // A single matching event, we assume all event handlers relate to it
384
+ const [matchingEvent] = matchingEvents;
385
+ // assign all interfaces at once if none is set
386
+ // but don't add to existing interfaces otherwise
387
+ if (!matchingEvent.targets) {
388
+ matchingEvent.targets = handledEventNames[eventName];
389
+ } else if (!hasStructuredData) {
390
+ const missingIface = handledEventNames[eventName]
391
+ .find(iface => !matchingEvent.targets.includes(iface));
392
+ if (missingIface) {
393
+ console.warn(`[reffy] More event handlers matching name ${eventName}, e.g. on ${missingIface} than ones identified in spec definitions`);
394
+ }
395
+ }
396
+ } else {
397
+ // More than one event with that name
398
+ // we can only check if this matches known information
399
+ // to warn of the gap otherwise
400
+ for (let iface of handledEventNames[eventName]) {
401
+ if (!matchingEvents.find(e => e.targets?.includes(iface))) {
402
+ console.warn(`[reffy] Could not determine which event named ${eventName} match EventHandler of ${iface} interface in ${spec.title}`);
403
+ }
404
+ }
405
+ }
406
+ }
407
+
408
+ // Find definitions marked as of event type
409
+ [...document.querySelectorAll('dfn[data-dfn-type="event"')].forEach(dfn => {
410
+ const type = dfn.textContent.trim();
411
+ const container = dfn.parentNode;
412
+ const event = {
413
+ type, interface: null, targets: fromEventElementToTargetInterfaces(dfn),
414
+ src: { format: "dfn", href: href(dfn.closest("*[id]")) },
415
+ href: href(dfn)
416
+ };
417
+ // CSS Animations & Transitions uses dt/dd to describe events
418
+ // and uses a ul in the dd to describe bubbling behavior
419
+ let bubbles, iface, cancelable;
420
+ if (container.tagName === "DT") {
421
+ const bubbleItem = [...container.nextElementSibling.querySelectorAll("li")]
422
+ .find(li => li.textContent.startsWith("Bubbles:"));
423
+ if (bubbleItem) {
424
+ bubbles = !!bubbleItem.textContent.match(/yes/i);
425
+ }
426
+ const cancelableItem = [...container.nextElementSibling.querySelectorAll("li")]
427
+ .find(li => li.textContent.startsWith("Cancelable:"));
428
+ if (cancelableItem) {
429
+ cancelable = !!cancelableItem.textContent.match(/yes/i);
430
+ }
431
+ // CSS Animation & Transitions document the event in the heading
432
+ // of the section where the definitions are located
433
+ let currentEl = container.parentNode;
434
+ while(currentEl) {
435
+ if (currentEl.tagName.match(/^H[1-6]$/)) {
436
+ break;
437
+ }
438
+ currentEl = currentEl.previousElementSibling;
439
+ }
440
+ const interfaceEl = currentEl?.querySelector("code");
441
+ if (interfaceEl?.textContent?.match(/^[A-Z][a-z]+Event$/)) {
442
+ iface = interfaceEl.textContent;
443
+ }
444
+ }
445
+ const ev = events.find(e => isSameEvent(event, e));
446
+ if (!ev) {
447
+ if (iface) {
448
+ event.interface = iface;
449
+ }
450
+ event.bubbles = bubbles;
451
+ event.cancelable = cancelable;
452
+ events.push(event);
453
+ if (!iface) {
454
+ console.error(`[reffy] No interface hint found for event definition ${event.type} in ${spec.title}`);
455
+ }
456
+ } else {
457
+ if (iface) {
458
+ ev.interface = iface;
459
+ }
460
+ if (!ev.href && event.href) {
461
+ ev.href = event.href;
462
+ }
463
+ if (bubbles !== undefined) {
464
+ ev.bubbles = bubbles;
465
+ }
466
+ if (cancelable !== undefined) {
467
+ ev.cancelable = cancelable;
468
+ }
469
+ }
470
+ });
471
+
472
+ return events
473
+ .map(e => {
474
+ // Drop null properties (mandated by the schema for event extracts)
475
+ if (e.hasOwnProperty('interface') && !e.interface) {
476
+ delete e.interface;
477
+ }
478
+ if (e.hasOwnProperty('href') && !e.href) {
479
+ delete e.href;
480
+ }
481
+ if (e.src && e.src.hasOwnProperty('href') && !e.src.href) {
482
+ delete e.src.href;
483
+ }
484
+ return e;
485
+ })
486
+ .map(e => e.href && !e.href.startsWith(window.location.toString()) ?
487
+ Object.assign(e, {isExtension: true}) :
488
+ e) ;
489
+ }