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