reffy 6.1.1 → 6.2.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +158 -158
  3. package/index.js +11 -11
  4. package/package.json +53 -53
  5. package/reffy.js +248 -236
  6. package/src/browserlib/canonicalize-url.mjs +50 -50
  7. package/src/browserlib/create-outline.mjs +352 -352
  8. package/src/browserlib/extract-cssdfn.mjs +319 -319
  9. package/src/browserlib/extract-dfns.mjs +686 -686
  10. package/src/browserlib/extract-editors.mjs~ +14 -0
  11. package/src/browserlib/extract-elements.mjs +205 -205
  12. package/src/browserlib/extract-headings.mjs +48 -48
  13. package/src/browserlib/extract-ids.mjs +28 -28
  14. package/src/browserlib/extract-links.mjs +28 -28
  15. package/src/browserlib/extract-references.mjs +203 -203
  16. package/src/browserlib/extract-webidl.mjs +134 -134
  17. package/src/browserlib/generate-es-dfn-report.sh~ +4 -0
  18. package/src/browserlib/get-absolute-url.mjs +21 -21
  19. package/src/browserlib/get-generator.mjs +26 -26
  20. package/src/browserlib/get-lastmodified-date.mjs +13 -13
  21. package/src/browserlib/get-title.mjs +11 -11
  22. package/src/browserlib/informative-selector.mjs +16 -16
  23. package/src/browserlib/map-ids-to-headings.mjs +136 -136
  24. package/src/browserlib/reffy.json +53 -53
  25. package/src/cli/check-missing-dfns.js +609 -609
  26. package/src/cli/csstree-grammar-check.js +28 -0
  27. package/src/cli/csstree-grammar-check.js~ +10 -0
  28. package/src/cli/csstree-grammar-parser.js +11 -0
  29. package/src/cli/csstree-grammar-parser.js~ +1 -0
  30. package/src/cli/extract-editors.js~ +38 -0
  31. package/src/cli/generate-idlnames.js +430 -430
  32. package/src/cli/generate-idlparsed.js +139 -139
  33. package/src/cli/merge-crawl-results.js +128 -128
  34. package/src/cli/parse-webidl.js +430 -430
  35. package/src/cli/process-specs.js~ +28 -0
  36. package/src/lib/css-grammar-parse-tree.schema.json +109 -109
  37. package/src/lib/css-grammar-parser.js +440 -440
  38. package/src/lib/fetch.js +55 -55
  39. package/src/lib/nock-server.js +119 -111
  40. package/src/lib/specs-crawler.js +603 -552
  41. package/src/lib/util.js +898 -865
  42. package/src/specs/missing-css-rules.json +197 -197
  43. package/src/specs/spec-equivalents.json +149 -149
@@ -1,552 +1,603 @@
1
- #!/usr/bin/env node
2
- /**
3
- * The spec crawler takes a list of spec URLs as input, gathers some knowledge
4
- * about these specs (published versions, URL of the Editor's Draft, etc.),
5
- * fetches these specs, parses them, extracts relevant information that they
6
- * contain (such as the WebIDL they define, the list of specifications that they
7
- * reference, and links to external specs), and produces a crawl report with the
8
- * results of these investigations.
9
- *
10
- * @module crawler
11
- */
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
- const specs = require('browser-specs');
16
- const cssDfnParser = require('./css-grammar-parser');
17
- const { generateIdlParsed, saveIdlParsed } = require('../cli/generate-idlparsed');
18
- const { generateIdlNames, saveIdlNames } = require('../cli/generate-idlnames');
19
- const {
20
- completeWithAlternativeUrls,
21
- expandBrowserModules,
22
- expandCrawlResult,
23
- getGeneratedIDLNamesByCSSProperty,
24
- isLatestLevelThatPasses,
25
- processSpecification,
26
- setupBrowser,
27
- teardownBrowser,
28
- createFolderIfNeeded
29
- } = require('./util');
30
-
31
-
32
- /**
33
- * Load and parse the given spec.
34
- *
35
- * @function
36
- * @param {Object} spec The spec to load (must already have been completed with
37
- * useful info, as returned by "createInitialSpecDescriptions")
38
- * @param {Object} crawlOptions Crawl options
39
- * @return {Promise<Object>} The promise to get a spec object with crawl info
40
- */
41
- async function crawlSpec(spec, crawlOptions) {
42
- crawlOptions = crawlOptions || {};
43
- spec.crawled = crawlOptions.publishedVersion ?
44
- (spec.release ? spec.release : spec.nightly) :
45
- spec.nightly;
46
-
47
- if (spec.error) {
48
- return spec;
49
- }
50
-
51
- try {
52
- const result = await processSpecification(
53
- spec.crawled,
54
- (spec, modules) => {
55
- const idToHeading = modules.find(m => m.needsIdToHeadingMap) ?
56
- window.reffy.mapIdsToHeadings() : null;
57
- const res = {
58
- crawled: window.location.toString()
59
- };
60
- modules.forEach(mod => {
61
- res[mod.property] = window.reffy[mod.name](spec, idToHeading);
62
- });
63
- return res;
64
- },
65
- [spec, crawlOptions.modules],
66
- { quiet: crawlOptions.quiet,
67
- forceLocalFetch: crawlOptions.forceLocalFetch }
68
- );
69
-
70
- // Specific rule for IDL extracts:
71
- // parse the extracted WebIdl content
72
- await generateIdlParsed(result);
73
-
74
- if (result.css) {
75
- // Specific rule for CSS properties:
76
- // Add CSS property definitions that weren't in a table
77
- if (result.dfns) {
78
- result.dfns
79
- .filter(dfn => dfn.type == "property" && !dfn.informative)
80
- .forEach(propDfn => {
81
- propDfn.linkingText.forEach(lt => {
82
- if (!result.css.properties.hasOwnProperty(lt)) {
83
- result.css.properties[lt] = {
84
- name: lt
85
- };
86
- }
87
- });
88
- });
89
- }
90
-
91
- // Specific rule for CSS properties:
92
- // Ideally, the sample definition (property-name) in CSS2 and the custom
93
- // property definition (--*) in CSS Variables would not be flagged as
94
- // real CSS properties. In practice, they are. Let's remove them from
95
- // the extract.
96
- ['property-name', '--*'].forEach(prop => {
97
- if ((result.css.properties || {})[prop]) {
98
- delete result.css.properties[prop];
99
- }
100
- });
101
-
102
- // Specific rule for CSS extracts:
103
- // Parse extracted CSS definitions and add generated IDL attribute names
104
- Object.entries(result.css.properties || {}).forEach(([prop, dfn]) => {
105
- if (dfn.value || dfn.newValues) {
106
- try {
107
- dfn.parsedValue = cssDfnParser.parsePropDefValue(
108
- dfn.value || dfn.newValues);
109
- } catch (e) {
110
- dfn.valueParseError = e.message;
111
- }
112
- }
113
- dfn.styleDeclaration = getGeneratedIDLNamesByCSSProperty(prop);
114
- });
115
- Object.entries(result.css.descriptors || {}).forEach(([desc, dfn]) => {
116
- if (dfn.value) {
117
- try {
118
- dfn.parsedValue = cssDfnParser.parsePropDefValue(
119
- dfn.value);
120
- } catch (e) {
121
- dfn.valueParseError = e.message;
122
- }
123
- }
124
- });
125
- Object.entries(result.css.valuespaces || {}).forEach(([vs, dfn]) => {
126
- if (dfn.value) {
127
- try {
128
- dfn.parsedValue = cssDfnParser.parsePropDefValue(
129
- dfn.value);
130
- } catch (e) {
131
- dfn.valueParseError = e.message;
132
- }
133
- }
134
- });
135
- }
136
-
137
- // Copy results back into initial spec object
138
- spec.crawled = result.crawled;
139
- crawlOptions.modules.forEach(mod => {
140
- if (result[mod.property]) {
141
- spec[mod.property] = result[mod.property];
142
- if (mod.property === 'idl') {
143
- spec.idlparsed = result.idlparsed;
144
- }
145
- }
146
- });
147
- }
148
- catch (err) {
149
- spec.title = spec.title || '[Could not be determined, see error]';
150
- spec.error = err.toString() + (err.stack ? ' ' + err.stack : '');
151
- }
152
-
153
- return spec;
154
- }
155
-
156
-
157
- /**
158
- * Saves spec results to extract files as needed and replaces the results with
159
- * links accordingly.
160
- *
161
- * @function
162
- * @param {Object} spec The results of crawling the spec. Object should contain
163
- * metadata about the spec and the crawl processing results in appropriate
164
- * properties.
165
- * @param {Object} settings Crawl settings. Recognized settings: "modules",
166
- * "output" and "quiet". See CLI help (node reffy.js --help) for details.
167
- * The "modules" setting is mandatory and note that the function will not do
168
- * anything if "output" is not set.
169
- * @return {Promise<Object>} The promise to get an updated spec object that
170
- * contains links to created extracts.
171
- */
172
- async function saveSpecResults(spec, settings) {
173
- settings = settings || {};
174
- if (!settings.output) {
175
- return spec;
176
- }
177
-
178
- async function getSubfolder(name) {
179
- let subfolder = path.join(settings.output, name);
180
- await createFolderIfNeeded(subfolder);
181
- return subfolder;
182
- }
183
-
184
- const modules = settings.modules;
185
- const folders = {};
186
- for (const mod of modules) {
187
- if (mod.metadata) {
188
- continue;
189
- }
190
- folders[mod.property] = await getSubfolder(mod.property);
191
-
192
- // Specific rule for IDL:
193
- // Raw IDL goes to "idl" subfolder, parsed IDL goes to "idlparsed"
194
- if (mod.property === 'idl') {
195
- folders.idlparsed = await getSubfolder('idlparsed');
196
- }
197
- }
198
-
199
- function getBaseJSON(spec) {
200
- return {
201
- spec: {
202
- title: spec.title,
203
- url: spec.crawled
204
- }
205
- };
206
- }
207
-
208
- async function saveExtract(spec, property, filter) {
209
- if (filter(spec)) {
210
- const contents = getBaseJSON(spec);
211
- contents[property] = spec[property];
212
- const json = JSON.stringify(contents, null, 2);
213
- const filename = path.join(folders[property], spec.shortname + '.json');
214
- await fs.promises.writeFile(filename, json);
215
- spec[property] = `${property}/${spec.shortname}.json`;
216
- }
217
- else {
218
- delete spec[property];
219
- }
220
- }
221
-
222
- async function saveIdl(spec) {
223
- let idlHeader = `
224
- // GENERATED CONTENT - DO NOT EDIT
225
- // Content was automatically extracted by Reffy into webref
226
- // (https://github.com/w3c/webref)
227
- // Source: ${spec.title} (${spec.crawled})`;
228
- idlHeader = idlHeader.replace(/^\s+/gm, '').trim() + '\n\n';
229
- const idl = idlHeader + spec.idl + '\n';
230
- await fs.promises.writeFile(
231
- path.join(folders.idl, spec.shortname + '.idl'), idl);
232
- return `idl/${spec.shortname}.idl`;
233
- };
234
-
235
- async function saveCss(spec) {
236
- // There are no comments in JSON, so include the spec title+URL as the
237
- // first property instead.
238
- const css = Object.assign(getBaseJSON(spec), spec.css);
239
- const json = JSON.stringify(css, (key, val) => {
240
- if ((key === 'parsedValue') || (key === 'valueParseError')) {
241
- return undefined;
242
- }
243
- else {
244
- return val;
245
- }
246
- }, 2) + '\n';
247
- const pathname = path.join(folders.css, spec.shortname + '.json')
248
- await fs.promises.writeFile(pathname, json);
249
- return `css/${spec.shortname}.json`;
250
- };
251
-
252
- // Save IDL dumps
253
- if (spec.idl) {
254
- spec.idl = await saveIdl(spec);
255
- }
256
- if (spec.idlparsed) {
257
- spec.idlparsed = await saveIdlParsed(spec, settings.output);
258
- }
259
-
260
- // Save CSS dumps
261
- function defineCSSContent(spec) {
262
- return spec.css && (
263
- (Object.keys(spec.css.properties || {}).length > 0) ||
264
- (Object.keys(spec.css.descriptors || {}).length > 0) ||
265
- (Object.keys(spec.css.valuespaces || {}).length > 0));
266
- }
267
- if (defineCSSContent(spec)) {
268
- spec.css = await saveCss(spec);
269
- }
270
-
271
- // Specs that define CSS now have a "css" key that point to the CSS extract.
272
- // Specs that don't define CSS still have a "css" key that points to an
273
- // empty object structure. Let's get rid of it.
274
- if (spec.css && typeof spec.css !== 'string') {
275
- delete spec.css;
276
- }
277
-
278
- // Quick and dirty function to determine whether a variable is "empty"
279
- // (it returns true for falsy values, which is good enough for what we need)
280
- function isEmpty(thing) {
281
- return !thing ||
282
- Array.isArray(thing) && (thing.length === 0) ||
283
- (typeof thing == 'object') && (Object.keys(thing).length === 0);
284
- }
285
-
286
- // Save all other extracts
287
- const remainingModules = modules.filter(mod =>
288
- !mod.metadata && mod.property !== 'css' && mod.property !== 'idl');
289
- for (const mod of remainingModules) {
290
- await saveExtract(spec, mod.property, spec => !isEmpty(spec[mod.property]));
291
- if (spec[mod.property] && typeof spec[mod.property] !== 'string') {
292
- delete spec[mod.property];
293
- }
294
- }
295
-
296
- return spec;
297
- }
298
-
299
-
300
- /**
301
- * Main method that crawls the list of specification URLs and return a structure
302
- * that full describes its title, URLs, references, and IDL definitions.
303
- *
304
- * @function
305
- * @param {Array(String)} speclist List of URLs to parse
306
- * @param {Object} crawlOptions Crawl options
307
- * @return {Promise<Array(Object)} The promise to get an array of complete
308
- * specification descriptions
309
- */
310
- async function crawlList(speclist, crawlOptions) {
311
- crawlOptions = crawlOptions || {};
312
-
313
- // Prepare Puppeteer instance
314
- crawlOptions.modules = expandBrowserModules(crawlOptions.modules);
315
- await setupBrowser(crawlOptions.modules);
316
-
317
- const list = speclist.map(completeWithAlternativeUrls);
318
- const listAndPromise = list.map(spec => {
319
- let resolve = null;
320
- let reject = null;
321
- let readyToCrawl = new Promise((resolveFunction, rejectFunction) => {
322
- resolve = resolveFunction;
323
- reject = rejectFunction;
324
- });
325
- return { spec, readyToCrawl, resolve, reject };
326
- });
327
-
328
- // In debug mode, specs are processed one by one. In normal mode,
329
- // specs are processing in chunks
330
- const chunkSize = Math.min((crawlOptions.debug ? 1 : 4), list.length);
331
-
332
- let pos = 0;
333
- function flagNextSpecAsReadyToCrawl() {
334
- if (pos < listAndPromise.length) {
335
- listAndPromise[pos].resolve();
336
- pos += 1;
337
- }
338
- }
339
- for (let i = 0; i < chunkSize; i++) {
340
- flagNextSpecAsReadyToCrawl();
341
- }
342
-
343
- const nbStr = '' + listAndPromise.length;
344
- async function crawlSpecAndPromise(specAndPromise, idx) {
345
- await specAndPromise.readyToCrawl;
346
- const spec = specAndPromise.spec;
347
- const logCounter = ('' + (idx + 1)).padStart(nbStr.length, ' ') + '/' + nbStr;
348
- crawlOptions.quiet ?? console.warn(`${logCounter} - ${spec.url} - crawling`);
349
- let result = await crawlSpec(spec, crawlOptions);
350
- result = await saveSpecResults(result, crawlOptions);
351
- crawlOptions.quiet ?? console.warn(`${logCounter} - ${spec.url} - done`);
352
- flagNextSpecAsReadyToCrawl();
353
-
354
- return result;
355
- }
356
-
357
- const results = await Promise.all(listAndPromise.map(crawlSpecAndPromise));
358
-
359
- // Close Puppeteer instance
360
- teardownBrowser();
361
-
362
- return results;
363
- }
364
-
365
-
366
- /**
367
- * Merges extracts per series for the given property and adjusts links
368
- *
369
- * @function
370
- * @param {Array(object)} data Crawl results
371
- * @param {string} property The extract property to process
372
- * @param {Object} settings Crawl settings. The function looks at the "output"
373
- * setting to determine where to look for extracts
374
- * @return {Promise(Array)} The promise to get an updated crawl results array
375
- */
376
- async function adjustExtractsPerSeries(data, property, settings) {
377
- if (!settings.output) {
378
- return data;
379
- }
380
-
381
- const fullLevels = data.filter(spec =>
382
- (spec.seriesComposition !== 'delta') &&
383
- isLatestLevelThatPasses(spec, data, spec => spec[property]));
384
- const deltaLevels = data.filter(spec =>
385
- (spec.seriesComposition === 'delta') && spec[property]);
386
-
387
- data.forEach(spec => {
388
- if (fullLevels.includes(spec)) {
389
- // Full level, rename the extract after the series' shortname
390
- const pathname = path.resolve(settings.output, spec[property]);
391
- spec[property] = `${property}/${spec.series.shortname}${path.extname(spec[property])}`;
392
- const newpathname = path.resolve(settings.output, spec[property]);
393
- fs.renameSync(pathname, newpathname);
394
- }
395
- else if (deltaLevels.includes(spec)) {
396
- // Delta level, need to keep the extract as-is
397
- }
398
- else if (spec[property]) {
399
- // Not the right full level in the series, drop created extract
400
- // and link to the series extract instead
401
- const pathname = path.resolve(settings.output, spec[property]);
402
- fs.unlinkSync(pathname);
403
- spec[property] = `${property}/${spec.series.shortname}${path.extname(spec[property])}`;
404
- }
405
- });
406
-
407
- return data;
408
- }
409
-
410
-
411
- /**
412
- * Saves the crawl results to an index.json file.
413
- *
414
- * @function
415
- * @param {Array(Object)} data The list of specification structures to save
416
- * @param {Object} settings Crawl settings. The function does not create any
417
- * save file if the "output" setting is not set.
418
- * @return {Promise<void>} The promise to have saved the data
419
- */
420
- async function saveResults(data, settings) {
421
- if (!settings.output) {
422
- return data;
423
- }
424
-
425
- // Save all results to an index.json file
426
- const indexFilename = path.join(settings.output, 'index.json');
427
- const contents = {
428
- type: 'crawl',
429
- title: 'Reffy crawl',
430
- date: (new Date()).toJSON(),
431
- options: settings,
432
- stats: {},
433
- results: data
434
- };
435
- contents.options.modules = contents.options.modules.map(mod => mod.property);
436
- contents.stats = {
437
- crawled: contents.results.length,
438
- errors: contents.results.filter(spec => !!spec.error).length
439
- };
440
-
441
- await fs.promises.writeFile(indexFilename, JSON.stringify(contents, null, 2));
442
- return contents;
443
- }
444
-
445
-
446
- /**
447
- * Crawls the specifications listed in the given JSON file and generates a
448
- * crawl report in the given folder.
449
- *
450
- * @function
451
- * @param {Object} options Crawl options. Possible options are:
452
- * publishedVersion, debug, output, terse, modules and specs.
453
- * See CLI help (node reffy.js --help) for details.
454
- * @return {Promise<void>} The promise that the crawl will have been made
455
- */
456
- function crawlSpecs(options) {
457
- function prepareListOfSpecs(list) {
458
- return list.map(spec => {
459
- if (typeof spec !== 'string') {
460
- return spec;
461
- }
462
- let match = specs.find(s => s.url === spec || s.shortname === spec);
463
- if (!match) {
464
- match = specs.find(s => s.series &&
465
- s.series.shortname === spec &&
466
- s.series.currentSpecification === s.shortname);
467
- }
468
- if (match) {
469
- return match;
470
- }
471
-
472
- let url = null;
473
- try {
474
- url = (new URL(spec)).href;
475
- }
476
- catch {
477
- if (spec.endsWith('.html')) {
478
- url = (new URL(spec, `file://${process.cwd()}/`)).href;
479
- }
480
- else {
481
- const msg = `Spec ID "${spec}" can neither be interpreted as a URL, a valid shortname or a relative path to an HTML file`;
482
- throw new Error(msg);
483
- }
484
- }
485
- return {
486
- url,
487
- nightly: { url },
488
- shortname: spec.replace(/[:\/\\\.]/g, ''),
489
- series: {
490
- shortname: spec.replace(/[:\/\\\.]/g, ''),
491
- }
492
- };
493
- });
494
- }
495
-
496
- const requestedList = (options && options.specs) ?
497
- prepareListOfSpecs(options.specs) :
498
- specs;
499
-
500
- return crawlList(requestedList, options)
501
- .then(async results => {
502
- // Merge extracts per series when necessary (CSS/IDL extracts)
503
- for (const mod of options.modules) {
504
- if (mod.extractsPerSeries) {
505
- await adjustExtractsPerSeries(results, mod.property, options);
506
- }
507
- }
508
- return results;
509
- })
510
- .then(results => {
511
- // Return results to the console or save crawl results to an
512
- // index.json file
513
- if (options.terse) {
514
- const property = options.modules[0].property;
515
- results = results.map(result => {
516
- let res = result[property];
517
- if (property === 'idl') {
518
- res = res?.idl;
519
- }
520
- return res;
521
- });
522
- if (results.length === 1) {
523
- results = results[0];
524
- }
525
- console.log(typeof results === 'string' ?
526
- results : JSON.stringify(results, null, 2));
527
- }
528
- else if (!options.output) {
529
- console.log(JSON.stringify(results, null, 2));
530
- }
531
- else {
532
- return saveResults(results, options);
533
- }
534
- })
535
- .then(async crawlIndex => {
536
- // Generate IDL names extracts from IDL extracts
537
- // (and dfns extracts to create links to definitions)
538
- if (!options.output || !crawlIndex?.options?.modules?.find(mod => mod === 'idl')) {
539
- return;
540
- }
541
- const crawlResults = await expandCrawlResult(crawlIndex, options.output, ['idlparsed', 'dfns']);
542
- const idlNames = generateIdlNames(crawlResults.results, options);
543
- await saveIdlNames(idlNames, options.output);
544
- });
545
- }
546
-
547
-
548
- /**************************************************
549
- Export methods for use as module
550
- **************************************************/
551
- module.exports.crawlList = crawlList;
552
- module.exports.crawlSpecs = crawlSpecs;
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The spec crawler takes a list of spec URLs as input, gathers some knowledge
4
+ * about these specs (published versions, URL of the Editor's Draft, etc.),
5
+ * fetches these specs, parses them, extracts relevant information that they
6
+ * contain (such as the WebIDL they define, the list of specifications that they
7
+ * reference, and links to external specs), and produces a crawl report with the
8
+ * results of these investigations.
9
+ *
10
+ * @module crawler
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const specs = require('browser-specs');
16
+ const cssDfnParser = require('./css-grammar-parser');
17
+ const { generateIdlParsed, saveIdlParsed } = require('../cli/generate-idlparsed');
18
+ const { generateIdlNames, saveIdlNames } = require('../cli/generate-idlnames');
19
+ const {
20
+ completeWithAlternativeUrls,
21
+ expandBrowserModules,
22
+ expandCrawlResult,
23
+ expandSpecResult,
24
+ getGeneratedIDLNamesByCSSProperty,
25
+ isLatestLevelThatPasses,
26
+ processSpecification,
27
+ setupBrowser,
28
+ teardownBrowser,
29
+ createFolderIfNeeded
30
+ } = require('./util');
31
+
32
+
33
+ /**
34
+ * Return the spec if crawl succeeded or crawl result from given fallback list
35
+ * if crawl yielded an error (and fallback does exist).
36
+ *
37
+ * The function keeps the "error" property on the crawl result it returns so
38
+ * that the error does not get entirely lost.
39
+ *
40
+ * @function
41
+ * @param {Object} spec Actual spec crawl result
42
+ * * @param {Object} spec Actual spec crawl result
43
+ * @param {String} fallbackFolder The folder that contains fallback extracts
44
+ * @param {Array<Object>} fallbackData A list of crawl results to use as
45
+ * fallback when needed
46
+ * @return {Object} The given crawl result or a new one that reuses fallback
47
+ * content if needed
48
+ */
49
+ async function specOrFallback(spec, fallbackFolder, fallbackData) {
50
+ if (spec.error && fallbackData) {
51
+ const fallback = fallbackData.find(s => s.url === spec.url);
52
+ if (fallback) {
53
+ const copy = Object.assign({}, fallback);
54
+ const result = await expandSpecResult(copy, fallbackFolder);
55
+ result.error = spec.error;
56
+ return result;
57
+ }
58
+ }
59
+ return spec;
60
+ }
61
+
62
+
63
+ /**
64
+ * Load and parse the given spec.
65
+ *
66
+ * @function
67
+ * @param {Object} spec The spec to load (must already have been completed with
68
+ * useful info, as returned by "createInitialSpecDescriptions")
69
+ * @param {Object} crawlOptions Crawl options
70
+ * @return {Promise<Object>} The promise to get a spec object with crawl info
71
+ */
72
+ async function crawlSpec(spec, crawlOptions) {
73
+ crawlOptions = crawlOptions || {};
74
+ spec.crawled = crawlOptions.publishedVersion ?
75
+ (spec.release ? spec.release : spec.nightly) :
76
+ spec.nightly;
77
+ const fallbackFolder = crawlOptions.fallback ?
78
+ path.dirname(crawlOptions.fallback) : '';
79
+
80
+ if (spec.error) {
81
+ return specOrFallback(spec, fallbackFolder, crawlOptions.fallbackData);
82
+ }
83
+
84
+ try {
85
+ const result = await processSpecification(
86
+ spec.crawled,
87
+ (spec, modules) => {
88
+ const idToHeading = modules.find(m => m.needsIdToHeadingMap) ?
89
+ window.reffy.mapIdsToHeadings() : null;
90
+ const res = {
91
+ crawled: window.location.toString()
92
+ };
93
+ modules.forEach(mod => {
94
+ res[mod.property] = window.reffy[mod.name](spec, idToHeading);
95
+ });
96
+ return res;
97
+ },
98
+ [spec, crawlOptions.modules],
99
+ { quiet: crawlOptions.quiet,
100
+ forceLocalFetch: crawlOptions.forceLocalFetch }
101
+ );
102
+
103
+ // Specific rule for IDL extracts:
104
+ // parse the extracted WebIdl content
105
+ await generateIdlParsed(result);
106
+
107
+ if (result.css) {
108
+ // Specific rule for CSS properties:
109
+ // Add CSS property definitions that weren't in a table
110
+ if (result.dfns) {
111
+ result.dfns
112
+ .filter(dfn => dfn.type == "property" && !dfn.informative)
113
+ .forEach(propDfn => {
114
+ propDfn.linkingText.forEach(lt => {
115
+ if (!result.css.properties.hasOwnProperty(lt)) {
116
+ result.css.properties[lt] = {
117
+ name: lt
118
+ };
119
+ }
120
+ });
121
+ });
122
+ }
123
+
124
+ // Specific rule for CSS properties:
125
+ // Ideally, the sample definition (property-name) in CSS2 and the custom
126
+ // property definition (--*) in CSS Variables would not be flagged as
127
+ // real CSS properties. In practice, they are. Let's remove them from
128
+ // the extract.
129
+ ['property-name', '--*'].forEach(prop => {
130
+ if ((result.css.properties || {})[prop]) {
131
+ delete result.css.properties[prop];
132
+ }
133
+ });
134
+
135
+ // Specific rule for CSS extracts:
136
+ // Parse extracted CSS definitions and add generated IDL attribute names
137
+ Object.entries(result.css.properties || {}).forEach(([prop, dfn]) => {
138
+ if (dfn.value || dfn.newValues) {
139
+ try {
140
+ dfn.parsedValue = cssDfnParser.parsePropDefValue(
141
+ dfn.value || dfn.newValues);
142
+ } catch (e) {
143
+ dfn.valueParseError = e.message;
144
+ }
145
+ }
146
+ dfn.styleDeclaration = getGeneratedIDLNamesByCSSProperty(prop);
147
+ });
148
+ Object.entries(result.css.descriptors || {}).forEach(([desc, dfn]) => {
149
+ if (dfn.value) {
150
+ try {
151
+ dfn.parsedValue = cssDfnParser.parsePropDefValue(
152
+ dfn.value);
153
+ } catch (e) {
154
+ dfn.valueParseError = e.message;
155
+ }
156
+ }
157
+ });
158
+ Object.entries(result.css.valuespaces || {}).forEach(([vs, dfn]) => {
159
+ if (dfn.value) {
160
+ try {
161
+ dfn.parsedValue = cssDfnParser.parsePropDefValue(
162
+ dfn.value);
163
+ } catch (e) {
164
+ dfn.valueParseError = e.message;
165
+ }
166
+ }
167
+ });
168
+ }
169
+
170
+ // Copy results back into initial spec object
171
+ spec.crawled = result.crawled;
172
+ crawlOptions.modules.forEach(mod => {
173
+ if (result[mod.property]) {
174
+ spec[mod.property] = result[mod.property];
175
+ if (mod.property === 'idl') {
176
+ spec.idlparsed = result.idlparsed;
177
+ }
178
+ }
179
+ });
180
+ }
181
+ catch (err) {
182
+ spec.title = spec.title || '[Could not be determined, see error]';
183
+ spec.error = err.toString() + (err.stack ? ' ' + err.stack : '');
184
+ }
185
+
186
+ return specOrFallback(spec, fallbackFolder, crawlOptions.fallbackData);
187
+ }
188
+
189
+
190
+ /**
191
+ * Saves spec results to extract files as needed and replaces the results with
192
+ * links accordingly.
193
+ *
194
+ * @function
195
+ * @param {Object} spec The results of crawling the spec. Object should contain
196
+ * metadata about the spec and the crawl processing results in appropriate
197
+ * properties.
198
+ * @param {Object} settings Crawl settings. Recognized settings: "modules",
199
+ * "output" and "quiet". See CLI help (node reffy.js --help) for details.
200
+ * The "modules" setting is mandatory and note that the function will not do
201
+ * anything if "output" is not set.
202
+ * @return {Promise<Object>} The promise to get an updated spec object that
203
+ * contains links to created extracts.
204
+ */
205
+ async function saveSpecResults(spec, settings) {
206
+ settings = settings || {};
207
+ if (!settings.output) {
208
+ return spec;
209
+ }
210
+
211
+ async function getSubfolder(name) {
212
+ let subfolder = path.join(settings.output, name);
213
+ await createFolderIfNeeded(subfolder);
214
+ return subfolder;
215
+ }
216
+
217
+ const modules = settings.modules;
218
+ const folders = {};
219
+ for (const mod of modules) {
220
+ if (mod.metadata) {
221
+ continue;
222
+ }
223
+ folders[mod.property] = await getSubfolder(mod.property);
224
+
225
+ // Specific rule for IDL:
226
+ // Raw IDL goes to "idl" subfolder, parsed IDL goes to "idlparsed"
227
+ if (mod.property === 'idl') {
228
+ folders.idlparsed = await getSubfolder('idlparsed');
229
+ }
230
+ }
231
+
232
+ function getBaseJSON(spec) {
233
+ return {
234
+ spec: {
235
+ title: spec.title,
236
+ url: spec.crawled
237
+ }
238
+ };
239
+ }
240
+
241
+ async function saveExtract(spec, property, filter) {
242
+ if (filter(spec)) {
243
+ const contents = getBaseJSON(spec);
244
+ contents[property] = spec[property];
245
+ const json = JSON.stringify(contents, null, 2);
246
+ const filename = path.join(folders[property], spec.shortname + '.json');
247
+ await fs.promises.writeFile(filename, json);
248
+ spec[property] = `${property}/${spec.shortname}.json`;
249
+ }
250
+ else {
251
+ delete spec[property];
252
+ }
253
+ }
254
+
255
+ async function saveIdl(spec) {
256
+ let idlHeader = `
257
+ // GENERATED CONTENT - DO NOT EDIT
258
+ // Content was automatically extracted by Reffy into webref
259
+ // (https://github.com/w3c/webref)
260
+ // Source: ${spec.title} (${spec.crawled})`;
261
+ idlHeader = idlHeader.replace(/^\s+/gm, '').trim() + '\n\n';
262
+ const idl = idlHeader + spec.idl + '\n';
263
+ await fs.promises.writeFile(
264
+ path.join(folders.idl, spec.shortname + '.idl'), idl);
265
+ return `idl/${spec.shortname}.idl`;
266
+ };
267
+
268
+ async function saveCss(spec) {
269
+ // There are no comments in JSON, so include the spec title+URL as the
270
+ // first property instead.
271
+ const css = Object.assign(getBaseJSON(spec), spec.css);
272
+ const json = JSON.stringify(css, (key, val) => {
273
+ if ((key === 'parsedValue') || (key === 'valueParseError')) {
274
+ return undefined;
275
+ }
276
+ else {
277
+ return val;
278
+ }
279
+ }, 2) + '\n';
280
+ const pathname = path.join(folders.css, spec.shortname + '.json')
281
+ await fs.promises.writeFile(pathname, json);
282
+ return `css/${spec.shortname}.json`;
283
+ };
284
+
285
+ // Save IDL dumps
286
+ if (spec.idl) {
287
+ spec.idl = await saveIdl(spec);
288
+ }
289
+ if (spec.idlparsed) {
290
+ spec.idlparsed = await saveIdlParsed(spec, settings.output);
291
+ }
292
+
293
+ // Save CSS dumps
294
+ function defineCSSContent(spec) {
295
+ return spec.css && (
296
+ (Object.keys(spec.css.properties || {}).length > 0) ||
297
+ (Object.keys(spec.css.descriptors || {}).length > 0) ||
298
+ (Object.keys(spec.css.valuespaces || {}).length > 0));
299
+ }
300
+ if (defineCSSContent(spec)) {
301
+ spec.css = await saveCss(spec);
302
+ }
303
+
304
+ // Specs that define CSS now have a "css" key that point to the CSS extract.
305
+ // Specs that don't define CSS still have a "css" key that points to an
306
+ // empty object structure. Let's get rid of it.
307
+ if (spec.css && typeof spec.css !== 'string') {
308
+ delete spec.css;
309
+ }
310
+
311
+ // Quick and dirty function to determine whether a variable is "empty"
312
+ // (it returns true for falsy values, which is good enough for what we need)
313
+ function isEmpty(thing) {
314
+ return !thing ||
315
+ Array.isArray(thing) && (thing.length === 0) ||
316
+ (typeof thing == 'object') && (Object.keys(thing).length === 0);
317
+ }
318
+
319
+ // Save all other extracts
320
+ const remainingModules = modules.filter(mod =>
321
+ !mod.metadata && mod.property !== 'css' && mod.property !== 'idl');
322
+ for (const mod of remainingModules) {
323
+ await saveExtract(spec, mod.property, spec => !isEmpty(spec[mod.property]));
324
+ if (spec[mod.property] && typeof spec[mod.property] !== 'string') {
325
+ delete spec[mod.property];
326
+ }
327
+ }
328
+
329
+ return spec;
330
+ }
331
+
332
+
333
+ /**
334
+ * Main method that crawls the list of specification URLs and return a structure
335
+ * that full describes its title, URLs, references, and IDL definitions.
336
+ *
337
+ * @function
338
+ * @param {Array(String)} speclist List of URLs to parse
339
+ * @param {Object} crawlOptions Crawl options
340
+ * @return {Promise<Array(Object)} The promise to get an array of complete
341
+ * specification descriptions
342
+ */
343
+ async function crawlList(speclist, crawlOptions) {
344
+ // Make a shallow copy of crawl options object since we're going
345
+ // to modify properties in place
346
+ crawlOptions = Object.assign({}, crawlOptions);
347
+
348
+ // Expand list of processing modules to use if not already done
349
+ crawlOptions.modules = expandBrowserModules(crawlOptions.modules);
350
+
351
+ // Load fallback data if necessary
352
+ if (crawlOptions.fallback) {
353
+ try {
354
+ crawlOptions.fallbackData = JSON.parse(await fs.promises.readFile(crawlOptions.fallback)).results;
355
+ } catch (e) {
356
+ throw new Error(`Could not parse fallback data file ${crawlOptions.fallback}`);
357
+ }
358
+ }
359
+
360
+ // Prepare Puppeteer instance
361
+ await setupBrowser(crawlOptions.modules);
362
+
363
+ const list = speclist.map(completeWithAlternativeUrls);
364
+ const listAndPromise = list.map(spec => {
365
+ let resolve = null;
366
+ let reject = null;
367
+ let readyToCrawl = new Promise((resolveFunction, rejectFunction) => {
368
+ resolve = resolveFunction;
369
+ reject = rejectFunction;
370
+ });
371
+ return { spec, readyToCrawl, resolve, reject };
372
+ });
373
+
374
+ // In debug mode, specs are processed one by one. In normal mode,
375
+ // specs are processing in chunks
376
+ const chunkSize = Math.min((crawlOptions.debug ? 1 : 4), list.length);
377
+
378
+ let pos = 0;
379
+ function flagNextSpecAsReadyToCrawl() {
380
+ if (pos < listAndPromise.length) {
381
+ listAndPromise[pos].resolve();
382
+ pos += 1;
383
+ }
384
+ }
385
+ for (let i = 0; i < chunkSize; i++) {
386
+ flagNextSpecAsReadyToCrawl();
387
+ }
388
+
389
+ const nbStr = '' + listAndPromise.length;
390
+ async function crawlSpecAndPromise(specAndPromise, idx) {
391
+ await specAndPromise.readyToCrawl;
392
+ const spec = specAndPromise.spec;
393
+ const logCounter = ('' + (idx + 1)).padStart(nbStr.length, ' ') + '/' + nbStr;
394
+ crawlOptions.quiet ?? console.warn(`${logCounter} - ${spec.url} - crawling`);
395
+ let result = await crawlSpec(spec, crawlOptions);
396
+ result = await saveSpecResults(result, crawlOptions);
397
+ crawlOptions.quiet ?? console.warn(`${logCounter} - ${spec.url} - done`);
398
+ flagNextSpecAsReadyToCrawl();
399
+
400
+ return result;
401
+ }
402
+
403
+ const results = await Promise.all(listAndPromise.map(crawlSpecAndPromise));
404
+
405
+ // Close Puppeteer instance
406
+ teardownBrowser();
407
+
408
+ return results;
409
+ }
410
+
411
+
412
+ /**
413
+ * Merges extracts per series for the given property and adjusts links
414
+ *
415
+ * @function
416
+ * @param {Array(object)} data Crawl results
417
+ * @param {string} property The extract property to process
418
+ * @param {Object} settings Crawl settings. The function looks at the "output"
419
+ * setting to determine where to look for extracts
420
+ * @return {Promise(Array)} The promise to get an updated crawl results array
421
+ */
422
+ async function adjustExtractsPerSeries(data, property, settings) {
423
+ if (!settings.output) {
424
+ return data;
425
+ }
426
+
427
+ const fullLevels = data.filter(spec =>
428
+ (spec.seriesComposition !== 'delta') &&
429
+ isLatestLevelThatPasses(spec, data, spec => spec[property]));
430
+ const deltaLevels = data.filter(spec =>
431
+ (spec.seriesComposition === 'delta') && spec[property]);
432
+
433
+ data.forEach(spec => {
434
+ if (fullLevels.includes(spec)) {
435
+ // Full level, rename the extract after the series' shortname
436
+ const pathname = path.resolve(settings.output, spec[property]);
437
+ spec[property] = `${property}/${spec.series.shortname}${path.extname(spec[property])}`;
438
+ const newpathname = path.resolve(settings.output, spec[property]);
439
+ fs.renameSync(pathname, newpathname);
440
+ }
441
+ else if (deltaLevels.includes(spec)) {
442
+ // Delta level, need to keep the extract as-is
443
+ }
444
+ else if (spec[property]) {
445
+ // Not the right full level in the series, drop created extract
446
+ // and link to the series extract instead
447
+ const pathname = path.resolve(settings.output, spec[property]);
448
+ fs.unlinkSync(pathname);
449
+ spec[property] = `${property}/${spec.series.shortname}${path.extname(spec[property])}`;
450
+ }
451
+ });
452
+
453
+ return data;
454
+ }
455
+
456
+
457
+ /**
458
+ * Saves the crawl results to an index.json file.
459
+ *
460
+ * @function
461
+ * @param {Array(Object)} data The list of specification structures to save
462
+ * @param {Object} settings Crawl settings. The function does not create any
463
+ * save file if the "output" setting is not set.
464
+ * @return {Promise<void>} The promise to have saved the data
465
+ */
466
+ async function saveResults(data, settings) {
467
+ if (!settings.output) {
468
+ return data;
469
+ }
470
+
471
+ // Save all results to an index.json file
472
+ const indexFilename = path.join(settings.output, 'index.json');
473
+ const contents = {
474
+ type: 'crawl',
475
+ title: 'Reffy crawl',
476
+ date: (new Date()).toJSON(),
477
+ options: settings,
478
+ stats: {},
479
+ results: data
480
+ };
481
+ contents.options.modules = contents.options.modules.map(mod => mod.property);
482
+ contents.stats = {
483
+ crawled: contents.results.length,
484
+ errors: contents.results.filter(spec => !!spec.error).length
485
+ };
486
+
487
+ await fs.promises.writeFile(indexFilename, JSON.stringify(contents, null, 2));
488
+ return contents;
489
+ }
490
+
491
+
492
+ /**
493
+ * Crawls the specifications listed in the given JSON file and generates a
494
+ * crawl report in the given folder.
495
+ *
496
+ * @function
497
+ * @param {Object} options Crawl options. Possible options are:
498
+ * publishedVersion, debug, output, terse, modules and specs.
499
+ * See CLI help (node reffy.js --help) for details.
500
+ * @return {Promise<void>} The promise that the crawl will have been made
501
+ */
502
+ function crawlSpecs(options) {
503
+ function prepareListOfSpecs(list) {
504
+ return list.map(spec => {
505
+ if (typeof spec !== 'string') {
506
+ return spec;
507
+ }
508
+ let match = specs.find(s => s.url === spec || s.shortname === spec);
509
+ if (!match) {
510
+ match = specs.find(s => s.series &&
511
+ s.series.shortname === spec &&
512
+ s.series.currentSpecification === s.shortname);
513
+ }
514
+ if (match) {
515
+ return match;
516
+ }
517
+
518
+ let url = null;
519
+ try {
520
+ url = (new URL(spec)).href;
521
+ }
522
+ catch {
523
+ if (spec.endsWith('.html')) {
524
+ url = (new URL(spec, `file://${process.cwd()}/`)).href;
525
+ }
526
+ else {
527
+ const msg = `Spec ID "${spec}" can neither be interpreted as a URL, a valid shortname or a relative path to an HTML file`;
528
+ throw new Error(msg);
529
+ }
530
+ }
531
+ return {
532
+ url,
533
+ nightly: { url },
534
+ shortname: spec.replace(/[:\/\\\.]/g, ''),
535
+ series: {
536
+ shortname: spec.replace(/[:\/\\\.]/g, ''),
537
+ }
538
+ };
539
+ });
540
+ }
541
+
542
+ const requestedList = options?.specs ?
543
+ prepareListOfSpecs(options.specs) :
544
+ specs;
545
+
546
+ // Make a shallow copy of passed options parameter and expand modules
547
+ // in place.
548
+ options = Object.assign({}, options);
549
+ options.modules = expandBrowserModules(options.modules);
550
+
551
+ return crawlList(requestedList, options)
552
+ .then(async results => {
553
+ // Merge extracts per series when necessary (CSS/IDL extracts)
554
+ for (const mod of options.modules) {
555
+ if (mod.extractsPerSeries) {
556
+ await adjustExtractsPerSeries(results, mod.property, options);
557
+ }
558
+ }
559
+ return results;
560
+ })
561
+ .then(results => {
562
+ // Return results to the console or save crawl results to an
563
+ // index.json file
564
+ if (options.terse) {
565
+ const property = options.modules[0].property;
566
+ results = results.map(result => {
567
+ let res = result[property];
568
+ if (property === 'idl') {
569
+ res = res?.idl;
570
+ }
571
+ return res;
572
+ });
573
+ if (results.length === 1) {
574
+ results = results[0];
575
+ }
576
+ console.log(typeof results === 'string' ?
577
+ results : JSON.stringify(results, null, 2));
578
+ }
579
+ else if (!options.output) {
580
+ console.log(JSON.stringify(results, null, 2));
581
+ }
582
+ else {
583
+ return saveResults(results, options);
584
+ }
585
+ })
586
+ .then(async crawlIndex => {
587
+ // Generate IDL names extracts from IDL extracts
588
+ // (and dfns extracts to create links to definitions)
589
+ if (!options.output || !crawlIndex?.options?.modules?.find(mod => mod === 'idl')) {
590
+ return;
591
+ }
592
+ const crawlResults = await expandCrawlResult(crawlIndex, options.output, ['idlparsed', 'dfns']);
593
+ const idlNames = generateIdlNames(crawlResults.results, options);
594
+ await saveIdlNames(idlNames, options.output);
595
+ });
596
+ }
597
+
598
+
599
+ /**************************************************
600
+ Export methods for use as module
601
+ **************************************************/
602
+ module.exports.crawlList = crawlList;
603
+ module.exports.crawlSpecs = crawlSpecs;