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