• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright Joyent, Inc. and other Node contributors.
2//
3// Permission is hereby granted, free of charge, to any person obtaining a
4// copy of this software and associated documentation files (the
5// "Software"), to deal in the Software without restriction, including
6// without limitation the rights to use, copy, modify, merge, publish,
7// distribute, sublicense, and/or sell copies of the Software, and to permit
8// persons to whom the Software is furnished to do so, subject to the
9// following conditions:
10//
11// The above copyright notice and this permission notice shall be included
12// in all copies or substantial portions of the Software.
13//
14// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
17// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
20// USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22'use strict';
23
24const unified = require('unified');
25const common = require('./common.js');
26const html = require('remark-html');
27const { selectAll } = require('unist-util-select');
28
29module.exports = { jsonAPI };
30
31// Unified processor: input is https://github.com/syntax-tree/mdast,
32// output is: https://gist.github.com/1777387.
33function jsonAPI({ filename }) {
34  return (tree, file) => {
35
36    const exampleHeading = /^example/i;
37    const metaExpr = /<!--([^=]+)=([^-]+)-->\n*/g;
38    const stabilityExpr = /^Stability: ([0-5])(?:\s*-\s*)?(.*)$/s;
39
40    // Extract definitions.
41    const definitions = selectAll('definition', tree);
42
43    // Determine the start, stop, and depth of each section.
44    const sections = [];
45    let section = null;
46    tree.children.forEach((node, i) => {
47      if (node.type === 'heading' &&
48          !exampleHeading.test(textJoin(node.children, file))) {
49        if (section) section.stop = i - 1;
50        section = { start: i, stop: tree.children.length, depth: node.depth };
51        sections.push(section);
52      }
53    });
54
55    // Collect and capture results.
56    const result = { type: 'module', source: filename };
57    while (sections.length > 0) {
58      doSection(sections.shift(), result);
59    }
60    file.json = result;
61
62    // Process a single section (recursively, including subsections).
63    function doSection(section, parent) {
64      if (section.depth - parent.depth > 1) {
65        throw new Error('Inappropriate heading level\n' +
66                        JSON.stringify(section));
67      }
68
69      const current = newSection(tree.children[section.start], file);
70      let nodes = tree.children.slice(section.start + 1, section.stop + 1);
71
72      // Sometimes we have two headings with a single blob of description.
73      // Treat as a clone.
74      if (
75        nodes.length === 0 && sections.length > 0 &&
76        section.depth === sections[0].depth
77      ) {
78        nodes = tree.children.slice(sections[0].start + 1,
79                                    sections[0].stop + 1);
80      }
81
82      // Extract (and remove) metadata that is not directly inferable
83      // from the markdown itself.
84      nodes.forEach((node, i) => {
85        // Input: <!-- name=module -->; output: {name: module}.
86        if (node.type === 'html') {
87          node.value = node.value.replace(metaExpr, (_0, key, value) => {
88            current[key.trim()] = value.trim();
89            return '';
90          });
91          if (!node.value.trim()) delete nodes[i];
92        }
93
94        // Process metadata:
95        // <!-- YAML
96        // added: v1.0.0
97        // -->
98        if (node.type === 'html' && common.isYAMLBlock(node.value)) {
99          current.meta = common.extractAndParseYAML(node.value);
100          delete nodes[i];
101        }
102
103        // Stability marker: > Stability: ...
104        if (
105          node.type === 'blockquote' && node.children.length === 1 &&
106          node.children[0].type === 'paragraph' &&
107          nodes.slice(0, i).every((node) => node.type === 'list')
108        ) {
109          const text = textJoin(node.children[0].children, file);
110          const stability = text.match(stabilityExpr);
111          if (stability) {
112            current.stability = parseInt(stability[1], 10);
113            current.stabilityText = stability[2].trim();
114            delete nodes[i];
115          }
116        }
117      });
118
119      // Compress the node array.
120      nodes = nodes.filter(() => true);
121
122      // If the first node is a list, extract it.
123      const list = nodes[0] && nodes[0].type === 'list' ?
124        nodes.shift() : null;
125
126      // Now figure out what this list actually means.
127      // Depending on the section type, the list could be different things.
128      const values = list ?
129        list.children.map((child) => parseListItem(child, file)) : [];
130
131      switch (current.type) {
132        case 'ctor':
133        case 'classMethod':
134        case 'method':
135          // Each item is an argument, unless the name is 'return',
136          // in which case it's the return value.
137          const sig = {};
138          sig.params = values.filter((value) => {
139            if (value.name === 'return') {
140              sig.return = value;
141              return false;
142            }
143            return true;
144          });
145          parseSignature(current.textRaw, sig);
146          current.signatures = [sig];
147          break;
148
149        case 'property':
150          // There should be only one item, which is the value.
151          // Copy the data up to the section.
152          if (values.length) {
153            const signature = values[0];
154
155            // Shove the name in there for properties,
156            // since they are always just going to be the value etc.
157            signature.textRaw = `\`${current.name}\` ${signature.textRaw}`;
158
159            for (const key in signature) {
160              if (signature[key]) {
161                if (key === 'type') {
162                  current.typeof = signature.type;
163                } else {
164                  current[key] = signature[key];
165                }
166              }
167            }
168          }
169          break;
170
171        case 'event':
172          // Event: each item is an argument.
173          current.params = values;
174          break;
175
176        default:
177          // If list wasn't consumed, put it back in the nodes list.
178          if (list) nodes.unshift(list);
179      }
180
181      // Convert remaining nodes to a 'desc'.
182      // Unified expects to process a string; but we ignore that as we
183      // already have pre-parsed input that we can inject.
184      if (nodes.length) {
185        if (current.desc) current.shortDesc = current.desc;
186
187        current.desc = unified()
188          .use(function() {
189            this.Parser = () => (
190              { type: 'root', children: nodes.concat(definitions) }
191            );
192          })
193          .use(html)
194          .processSync('').toString().trim();
195        if (!current.desc) delete current.desc;
196      }
197
198      // Process subsections.
199      while (sections.length > 0 && sections[0].depth > section.depth) {
200        doSection(sections.shift(), current);
201      }
202
203      // If type is not set, default type based on parent type, and
204      // set displayName and name properties.
205      if (!current.type) {
206        current.type = (parent.type === 'misc' ? 'misc' : 'module');
207        current.displayName = current.name;
208        current.name = current.name.toLowerCase()
209          .trim().replace(/\s+/g, '_');
210      }
211
212      // Pluralize type to determine which 'bucket' to put this section in.
213      let plur;
214      if (current.type.slice(-1) === 's') {
215        plur = `${current.type}es`;
216      } else if (current.type.slice(-1) === 'y') {
217        plur = current.type.replace(/y$/, 'ies');
218      } else {
219        plur = `${current.type}s`;
220      }
221
222      // Classes sometimes have various 'ctor' children
223      // which are actually just descriptions of a constructor class signature.
224      // Merge them into the parent.
225      if (current.type === 'class' && current.ctors) {
226        current.signatures = current.signatures || [];
227        const sigs = current.signatures;
228        current.ctors.forEach((ctor) => {
229          ctor.signatures = ctor.signatures || [{}];
230          ctor.signatures.forEach((sig) => {
231            sig.desc = ctor.desc;
232          });
233          sigs.push(...ctor.signatures);
234        });
235        delete current.ctors;
236      }
237
238      // Properties are a bit special.
239      // Their "type" is the type of object, not "property".
240      if (current.type === 'property') {
241        if (current.typeof) {
242          current.type = current.typeof;
243          delete current.typeof;
244        } else {
245          delete current.type;
246        }
247      }
248
249      // If the parent's type is 'misc', then it's just a random
250      // collection of stuff, like the "globals" section.
251      // Make the children top-level items.
252      if (current.type === 'misc') {
253        Object.keys(current).forEach((key) => {
254          switch (key) {
255            case 'textRaw':
256            case 'name':
257            case 'type':
258            case 'desc':
259            case 'miscs':
260              return;
261            default:
262              if (parent.type === 'misc') {
263                return;
264              }
265              if (parent[key] && Array.isArray(parent[key])) {
266                parent[key] = parent[key].concat(current[key]);
267              } else if (!parent[key]) {
268                parent[key] = current[key];
269              }
270          }
271        });
272      }
273
274      // Add this section to the parent. Sometimes we have two headings with a
275      // single blob of description. If the preceding entry at this level
276      // shares a name and is lacking a description, copy it backwards.
277      if (!parent[plur]) parent[plur] = [];
278      const prev = parent[plur].slice(-1)[0];
279      if (prev && prev.name === current.name && !prev.desc) {
280        prev.desc = current.desc;
281      }
282      parent[plur].push(current);
283    }
284  };
285}
286
287
288const paramExpr = /\((.+)\);?$/;
289
290// text: "someobject.someMethod(a[, b=100][, c])"
291function parseSignature(text, sig) {
292  const list = [];
293
294  let [, sigParams] = text.match(paramExpr) || [];
295  if (!sigParams) return;
296  sigParams = sigParams.split(',');
297  let optionalLevel = 0;
298  const optionalCharDict = { '[': 1, ' ': 0, ']': -1 };
299  sigParams.forEach((sigParam, i) => {
300    sigParam = sigParam.trim();
301    if (!sigParam) {
302      throw new Error(`Empty parameter slot: ${text}`);
303    }
304    let listParam = sig.params[i];
305    let optional = false;
306    let defaultValue;
307
308    // For grouped optional params such as someMethod(a[, b[, c]]).
309    let pos;
310    for (pos = 0; pos < sigParam.length; pos++) {
311      const levelChange = optionalCharDict[sigParam[pos]];
312      if (levelChange === undefined) break;
313      optionalLevel += levelChange;
314    }
315    sigParam = sigParam.substring(pos);
316    optional = (optionalLevel > 0);
317    for (pos = sigParam.length - 1; pos >= 0; pos--) {
318      const levelChange = optionalCharDict[sigParam[pos]];
319      if (levelChange === undefined) break;
320      optionalLevel += levelChange;
321    }
322    sigParam = sigParam.substring(0, pos + 1);
323
324    const eq = sigParam.indexOf('=');
325    if (eq !== -1) {
326      defaultValue = sigParam.substr(eq + 1);
327      sigParam = sigParam.substr(0, eq);
328    }
329
330    // At this point, the name should match. If it doesn't find one that does.
331    // Example: shared signatures for:
332    //   ### new Console(stdout[, stderr][, ignoreErrors])
333    //   ### new Console(options)
334    if (!listParam || sigParam !== listParam.name) {
335      listParam = null;
336      for (const param of sig.params) {
337        if (param.name === sigParam) {
338          listParam = param;
339        } else if (param.options) {
340          for (const option of param.options) {
341            if (option.name === sigParam) {
342              listParam = Object.assign({}, option);
343            }
344          }
345        }
346      }
347
348      if (!listParam) {
349        if (sigParam.startsWith('...')) {
350          listParam = { name: sigParam };
351        } else {
352          throw new Error(
353            `Invalid param "${sigParam}"\n` +
354            ` > ${JSON.stringify(listParam)}\n` +
355            ` > ${text}`
356          );
357        }
358      }
359    }
360
361    if (optional) listParam.optional = true;
362    if (defaultValue !== undefined) listParam.default = defaultValue.trim();
363
364    list.push(listParam);
365  });
366
367  sig.params = list;
368}
369
370
371const returnExpr = /^returns?\s*:?\s*/i;
372const nameExpr = /^['`"]?([^'`": {]+)['`"]?\s*:?\s*/;
373const typeExpr = /^\{([^}]+)\}\s*/;
374const leadingHyphen = /^-\s*/;
375const defaultExpr = /\s*\*\*Default:\*\*\s*([^]+)$/i;
376
377function parseListItem(item, file) {
378  const current = {};
379
380  current.textRaw = item.children.filter((node) => node.type !== 'list')
381    .map((node) => (
382      file.contents.slice(node.position.start.offset, node.position.end.offset))
383    )
384    .join('').replace(/\s+/g, ' ').replace(/<!--.*?-->/sg, '');
385  let text = current.textRaw;
386
387  if (!text) {
388    throw new Error(`Empty list item: ${JSON.stringify(item)}`);
389  }
390
391  // The goal here is to find the name, type, default.
392  // Anything left over is 'desc'.
393
394  if (returnExpr.test(text)) {
395    current.name = 'return';
396    text = text.replace(returnExpr, '');
397  } else {
398    const [, name] = text.match(nameExpr) || [];
399    if (name) {
400      current.name = name;
401      text = text.replace(nameExpr, '');
402    }
403  }
404
405  const [, type] = text.match(typeExpr) || [];
406  if (type) {
407    current.type = type;
408    text = text.replace(typeExpr, '');
409  }
410
411  text = text.replace(leadingHyphen, '');
412
413  const [, defaultValue] = text.match(defaultExpr) || [];
414  if (defaultValue) {
415    current.default = defaultValue.replace(/\.$/, '');
416    text = text.replace(defaultExpr, '');
417  }
418
419  if (text) current.desc = text;
420
421  const options = item.children.find((child) => child.type === 'list');
422  if (options) {
423    current.options = options.children.map((child) => (
424      parseListItem(child, file)
425    ));
426  }
427
428  return current;
429}
430
431// This section parses out the contents of an H# tag.
432
433// To reduce escape slashes in RegExp string components.
434const r = String.raw;
435
436const eventPrefix = '^Event: +';
437const classPrefix = '^[Cc]lass: +';
438const ctorPrefix = '^(?:[Cc]onstructor: +)?`?new +';
439const classMethodPrefix = '^Static method: +';
440const maybeClassPropertyPrefix = '(?:Class property: +)?';
441
442const maybeQuote = '[\'"]?';
443const notQuotes = '[^\'"]+';
444
445const maybeBacktick = '`?';
446
447// To include constructs like `readable\[Symbol.asyncIterator\]()`
448// or `readable.\_read(size)` (with Markdown escapes).
449const simpleId = r`(?:(?:\\?_)+|\b)\w+\b`;
450const computedId = r`\\?\[[\w\.]+\\?\]`;
451const id = `(?:${simpleId}|${computedId})`;
452const classId = r`[A-Z]\w+`;
453
454const ancestors = r`(?:${id}\.?)+`;
455const maybeAncestors = r`(?:${id}\.?)*`;
456
457const callWithParams = r`\([^)]*\)`;
458
459const maybeExtends = `(?: +extends +${maybeAncestors}${classId})?`;
460
461/* eslint-disable max-len */
462const headingExpressions = [
463  { type: 'event', re: RegExp(
464    `${eventPrefix}${maybeBacktick}${maybeQuote}(${notQuotes})${maybeQuote}${maybeBacktick}$`, 'i') },
465
466  { type: 'class', re: RegExp(
467    `${classPrefix}${maybeBacktick}(${maybeAncestors}${classId})${maybeExtends}${maybeBacktick}$`, '') },
468
469  { type: 'ctor', re: RegExp(
470    `${ctorPrefix}(${maybeAncestors}${classId})${callWithParams}${maybeBacktick}$`, '') },
471
472  { type: 'classMethod', re: RegExp(
473    `${classMethodPrefix}${maybeBacktick}${maybeAncestors}(${id})${callWithParams}${maybeBacktick}$`, 'i') },
474
475  { type: 'method', re: RegExp(
476    `^${maybeBacktick}${maybeAncestors}(${id})${callWithParams}${maybeBacktick}$`, 'i') },
477
478  { type: 'property', re: RegExp(
479    `^${maybeClassPropertyPrefix}${maybeBacktick}${ancestors}(${id})${maybeBacktick}$`, 'i') },
480];
481/* eslint-enable max-len */
482
483function newSection(header, file) {
484  const text = textJoin(header.children, file);
485
486  // Infer the type from the text.
487  for (const { type, re } of headingExpressions) {
488    const [, name] = text.match(re) || [];
489    if (name) {
490      return { textRaw: text, type, name };
491    }
492  }
493  return { textRaw: text, name: text };
494}
495
496function textJoin(nodes, file) {
497  return nodes.map((node) => {
498    if (node.type === 'linkReference') {
499      return file.contents.slice(node.position.start.offset,
500                                 node.position.end.offset);
501    } else if (node.type === 'inlineCode') {
502      return `\`${node.value}\``;
503    } else if (node.type === 'strong') {
504      return `**${textJoin(node.children, file)}**`;
505    } else if (node.type === 'emphasis') {
506      return `_${textJoin(node.children, file)}_`;
507    } else if (node.children) {
508      return textJoin(node.children, file);
509    }
510    return node.value;
511  }).join('');
512}
513