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