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 fs from 'fs'; 23import path from 'path'; 24 25import highlightJs from 'highlight.js'; 26import raw from 'rehype-raw'; 27import htmlStringify from 'rehype-stringify'; 28import gfm from 'remark-gfm'; 29import markdown from 'remark-parse'; 30import remark2rehype from 'remark-rehype'; 31import unified from 'unified'; 32import { visit } from 'unist-util-visit'; 33 34import * as common from './common.mjs'; 35import * as typeParser from './type-parser.mjs'; 36 37const { highlight, getLanguage } = highlightJs; 38 39const docPath = new URL('../../doc/', import.meta.url); 40 41// Add class attributes to index navigation links. 42function navClasses() { 43 return (tree) => { 44 visit(tree, { type: 'element', tagName: 'a' }, (node) => { 45 node.properties.class = 'nav-' + 46 node.properties.href.replace('.html', '').replace(/\W+/g, '-'); 47 }); 48 }; 49} 50 51const gtocPath = new URL('./api/index.md', docPath); 52const gtocMD = fs.readFileSync(gtocPath, 'utf8') 53 .replace(/\(([^#?]+?)\.md\)/ig, (_, filename) => `(${filename}.html)`) 54 .replace(/^<!--.*?-->/gms, ''); 55const gtocHTML = unified() 56 .use(markdown) 57 .use(gfm) 58 .use(remark2rehype, { allowDangerousHtml: true }) 59 .use(raw) 60 .use(navClasses) 61 .use(htmlStringify) 62 .processSync(gtocMD).toString(); 63 64const templatePath = new URL('./template.html', docPath); 65const template = fs.readFileSync(templatePath, 'utf8'); 66 67function processContent(content) { 68 content = content.toString(); 69 // Increment header tag levels to avoid multiple h1 tags in a doc. 70 // This means we can't already have an <h6>. 71 if (content.includes('<h6>')) { 72 throw new Error('Cannot increment a level 6 header'); 73 } 74 // `++level` to convert the string to a number and increment it. 75 content = content.replace(/(?<=<\/?h)[1-5](?=[^<>]*>)/g, (level) => ++level); 76 // Wrap h3 tags in section tags. 77 let firstTime = true; 78 return content 79 .replace(/<h3/g, (heading) => { 80 if (firstTime) { 81 firstTime = false; 82 return '<section>' + heading; 83 } 84 return '</section><section>' + heading; 85 }) + (firstTime ? '' : '</section>'); 86} 87 88export function toHTML({ input, content, filename, nodeVersion, versions }) { 89 filename = path.basename(filename, '.md'); 90 91 const id = filename.replace(/\W+/g, '-'); 92 93 let HTML = template.replace('__ID__', id) 94 .replace(/__FILENAME__/g, filename) 95 .replace('__SECTION__', content.section) 96 .replace(/__VERSION__/g, nodeVersion) 97 .replace('__TOC__', content.toc) 98 .replace('__GTOC__', gtocHTML.replace( 99 `class="nav-${id}"`, `class="nav-${id} active"`)) 100 .replace('__EDIT_ON_GITHUB__', editOnGitHub(filename)) 101 .replace('__CONTENT__', processContent(content)); 102 103 const docCreated = input.match( 104 /<!--\s*introduced_in\s*=\s*v([0-9]+)\.([0-9]+)\.[0-9]+\s*-->/); 105 if (docCreated) { 106 HTML = HTML.replace('__ALTDOCS__', altDocs(filename, docCreated, versions)); 107 } else { 108 console.error(`Failed to add alternative version links to ${filename}`); 109 HTML = HTML.replace('__ALTDOCS__', ''); 110 } 111 112 return HTML; 113} 114 115// Set the section name based on the first header. Default to 'Index'. 116export function firstHeader() { 117 return (tree, file) => { 118 let heading; 119 visit(tree, (node) => { 120 if (node.type === 'heading') { 121 heading = node; 122 return false; 123 } 124 }); 125 126 if (heading && heading.children.length) { 127 const recursiveTextContent = (node) => 128 node.value || node.children.map(recursiveTextContent).join(''); 129 file.section = recursiveTextContent(heading); 130 } else { 131 file.section = 'Index'; 132 } 133 }; 134} 135 136// Handle general body-text replacements. 137// For example, link man page references to the actual page. 138export function preprocessText({ nodeVersion }) { 139 return (tree) => { 140 visit(tree, null, (node) => { 141 if (common.isSourceLink(node.value)) { 142 const [path] = node.value.match(/(?<=<!-- source_link=).*(?= -->)/); 143 node.value = `<p><strong>Source Code:</strong> <a href="https://github.com/nodejs/node/blob/${nodeVersion}/${path}">${path}</a></p>`; 144 } else if (node.type === 'text' && node.value) { 145 const value = linkJsTypeDocs(linkManPages(node.value)); 146 if (value !== node.value) { 147 node.type = 'html'; 148 node.value = value; 149 } 150 } 151 }); 152 }; 153} 154 155// Syscalls which appear in the docs, but which only exist in BSD / macOS. 156const BSD_ONLY_SYSCALLS = new Set(['lchmod']); 157const MAN_PAGE = /(^|\s)([a-z.]+)\((\d)([a-z]?)\)/gm; 158 159// Handle references to man pages, eg "open(2)" or "lchmod(2)". 160// Returns modified text, with such refs replaced with HTML links, for example 161// '<a href="http://man7.org/linux/man-pages/man2/open.2.html">open(2)</a>'. 162function linkManPages(text) { 163 return text.replace( 164 MAN_PAGE, (match, beginning, name, number, optionalCharacter) => { 165 // Name consists of lowercase letters, 166 // number is a single digit with an optional lowercase letter. 167 const displayAs = `<code>${name}(${number}${optionalCharacter})</code>`; 168 169 if (BSD_ONLY_SYSCALLS.has(name)) { 170 return `${beginning}<a href="https://www.freebsd.org/cgi/man.cgi?query=${name}&sektion=${number}">${displayAs}</a>`; 171 } 172 173 return `${beginning}<a href="http://man7.org/linux/man-pages/man${number}/${name}.${number}${optionalCharacter}.html">${displayAs}</a>`; 174 }); 175} 176 177const TYPE_SIGNATURE = /\{[^}]+\}/g; 178function linkJsTypeDocs(text) { 179 const parts = text.split('`'); 180 181 // Handle types, for example the source Markdown might say 182 // "This argument should be a {number} or {string}". 183 for (let i = 0; i < parts.length; i += 2) { 184 const typeMatches = parts[i].match(TYPE_SIGNATURE); 185 if (typeMatches) { 186 typeMatches.forEach((typeMatch) => { 187 parts[i] = parts[i].replace(typeMatch, typeParser.toLink(typeMatch)); 188 }); 189 } 190 } 191 192 return parts.join('`'); 193} 194 195const isJSFlavorSnippet = (node) => node.lang === 'cjs' || node.lang === 'mjs'; 196 197// Preprocess headers, stability blockquotes, and YAML blocks. 198export function preprocessElements({ filename }) { 199 return (tree) => { 200 const STABILITY_RE = /(.*:)\s*(\d)([\s\S]*)/; 201 let headingIndex = -1; 202 let heading = null; 203 204 visit(tree, null, (node, index, parent) => { 205 if (node.type === 'heading') { 206 headingIndex = index; 207 heading = node; 208 } else if (node.type === 'code') { 209 if (!node.lang) { 210 console.warn( 211 `No language set in ${filename}, line ${node.position.start.line}` 212 ); 213 } 214 const className = isJSFlavorSnippet(node) ? 215 `language-js ${node.lang}` : 216 `language-${node.lang}`; 217 const highlighted = 218 `<code class='${className}'>${(getLanguage(node.lang || '') ? highlight(node.value, { language: node.lang }) : node).value}</code>`; 219 node.type = 'html'; 220 221 if (isJSFlavorSnippet(node)) { 222 const previousNode = parent.children[index - 1] || {}; 223 const nextNode = parent.children[index + 1] || {}; 224 225 if (!isJSFlavorSnippet(previousNode) && 226 isJSFlavorSnippet(nextNode) && 227 nextNode.lang !== node.lang) { 228 // Saving the highlight code as value to be added in the next node. 229 node.value = highlighted; 230 } else if (isJSFlavorSnippet(previousNode)) { 231 node.value = '<pre>' + 232 '<input class="js-flavor-selector" type="checkbox"' + 233 // If CJS comes in second, ESM should display by default. 234 (node.lang === 'cjs' ? ' checked' : '') + 235 ' aria-label="Show modern ES modules syntax">' + 236 previousNode.value + 237 highlighted + 238 '</pre>'; 239 node.lang = null; 240 previousNode.value = ''; 241 previousNode.lang = null; 242 } else { 243 // Isolated JS snippet, no need to add the checkbox. 244 node.value = `<pre>${highlighted}</pre>`; 245 } 246 } else { 247 node.value = `<pre>${highlighted}</pre>`; 248 } 249 } else if (node.type === 'html' && common.isYAMLBlock(node.value)) { 250 node.value = parseYAML(node.value); 251 252 } else if (node.type === 'blockquote') { 253 const paragraph = node.children[0].type === 'paragraph' && 254 node.children[0]; 255 const text = paragraph && paragraph.children[0].type === 'text' && 256 paragraph.children[0]; 257 if (text && text.value.includes('Stability:')) { 258 const [, prefix, number, explication] = 259 text.value.match(STABILITY_RE); 260 261 // Stability indices are never more than 3 nodes away from their 262 // heading. 263 const isStabilityIndex = index - headingIndex <= 3; 264 265 if (heading && isStabilityIndex) { 266 heading.stability = number; 267 headingIndex = -1; 268 heading = null; 269 } 270 271 // Do not link to the section we are already in. 272 const noLinking = filename.includes('documentation') && 273 heading !== null && heading.children[0].value === 'Stability index'; 274 275 // Collapse blockquote and paragraph into a single node 276 node.type = 'paragraph'; 277 node.children.shift(); 278 node.children.unshift(...paragraph.children); 279 280 // Insert div with prefix and number 281 node.children.unshift({ 282 type: 'html', 283 value: `<div class="api_stability api_stability_${number}">` + 284 (noLinking ? '' : 285 '<a href="documentation.html#documentation_stability_index">') + 286 `${prefix} ${number}${noLinking ? '' : '</a>'}` 287 .replace(/\n/g, ' ') 288 }); 289 290 // Remove prefix and number from text 291 text.value = explication; 292 293 // close div 294 node.children.push({ type: 'html', value: '</div>' }); 295 } 296 } 297 }); 298 }; 299} 300 301function parseYAML(text) { 302 const meta = common.extractAndParseYAML(text); 303 let result = '<div class="api_metadata">\n'; 304 305 const added = { description: '' }; 306 const deprecated = { description: '' }; 307 const removed = { description: '' }; 308 309 if (meta.added) { 310 added.version = meta.added.join(', '); 311 added.description = `<span>Added in: ${added.version}</span>`; 312 } 313 314 if (meta.deprecated) { 315 deprecated.version = meta.deprecated.join(', '); 316 deprecated.description = 317 `<span>Deprecated since: ${deprecated.version}</span>`; 318 } 319 320 if (meta.removed) { 321 removed.version = meta.removed.join(', '); 322 removed.description = `<span>Removed in: ${removed.version}</span>`; 323 } 324 325 if (meta.changes.length > 0) { 326 if (added.description) meta.changes.push(added); 327 if (deprecated.description) meta.changes.push(deprecated); 328 if (removed.description) meta.changes.push(removed); 329 330 meta.changes.sort((a, b) => versionSort(a.version, b.version)); 331 332 result += '<details class="changelog"><summary>History</summary>\n' + 333 '<table>\n<tr><th>Version</th><th>Changes</th></tr>\n'; 334 335 meta.changes.forEach((change) => { 336 const description = unified() 337 .use(markdown) 338 .use(gfm) 339 .use(remark2rehype, { allowDangerousHtml: true }) 340 .use(raw) 341 .use(htmlStringify) 342 .processSync(change.description).toString(); 343 344 const version = common.arrify(change.version).join(', '); 345 346 result += `<tr><td>${version}</td>\n` + 347 `<td>${description}</td></tr>\n`; 348 }); 349 350 result += '</table>\n</details>\n'; 351 } else { 352 result += `${added.description}${deprecated.description}${removed.description}\n`; 353 } 354 355 if (meta.napiVersion) { 356 result += `<span>N-API version: ${meta.napiVersion.join(', ')}</span>\n`; 357 } 358 359 result += '</div>'; 360 return result; 361} 362 363function minVersion(a) { 364 return common.arrify(a).reduce((min, e) => { 365 return !min || versionSort(min, e) < 0 ? e : min; 366 }); 367} 368 369const numberRe = /^\d*/; 370function versionSort(a, b) { 371 a = minVersion(a).trim(); 372 b = minVersion(b).trim(); 373 let i = 0; // Common prefix length. 374 while (i < a.length && i < b.length && a[i] === b[i]) i++; 375 a = a.substr(i); 376 b = b.substr(i); 377 return +b.match(numberRe)[0] - +a.match(numberRe)[0]; 378} 379 380const DEPRECATION_HEADING_PATTERN = /^DEP\d+:/; 381export function buildToc({ filename, apilinks }) { 382 return (tree, file) => { 383 const idCounters = Object.create(null); 384 let toc = ''; 385 let depth = 0; 386 387 visit(tree, null, (node) => { 388 if (node.type !== 'heading') return; 389 390 if (node.depth - depth > 1) { 391 throw new Error( 392 `Inappropriate heading level:\n${JSON.stringify(node)}` 393 ); 394 } 395 396 depth = node.depth; 397 const realFilename = path.basename(filename, '.md'); 398 const headingText = file.contents.slice( 399 node.children[0].position.start.offset, 400 node.position.end.offset).trim(); 401 const id = getId(`${realFilename}_${headingText}`, idCounters); 402 403 const isDeprecationHeading = 404 DEPRECATION_HEADING_PATTERN.test(headingText); 405 if (isDeprecationHeading) { 406 if (!node.data) node.data = {}; 407 if (!node.data.hProperties) node.data.hProperties = {}; 408 node.data.hProperties.id = 409 headingText.substring(0, headingText.indexOf(':')); 410 } 411 412 const hasStability = node.stability !== undefined; 413 toc += ' '.repeat((depth - 1) * 2) + 414 (hasStability ? `* <span class="stability_${node.stability}">` : '* ') + 415 `<a href="#${isDeprecationHeading ? node.data.hProperties.id : id}">${headingText}</a>${hasStability ? '</span>' : ''}\n`; 416 417 let anchor = 418 `<span><a class="mark" href="#${id}" id="${id}">#</a></span>`; 419 420 if (realFilename === 'errors' && headingText.startsWith('ERR_')) { 421 anchor += 422 `<span><a class="mark" href="#${headingText}" id="${headingText}">#</a></span>`; 423 } 424 425 const api = headingText.replace(/^.*:\s+/, '').replace(/\(.*/, ''); 426 if (apilinks[api]) { 427 anchor = `<a class="srclink" href=${apilinks[api]}>[src]</a>${anchor}`; 428 } 429 430 node.children.push({ type: 'html', value: anchor }); 431 }); 432 433 if (toc !== '') { 434 file.toc = '<details id="toc" open><summary>Table of contents</summary>' + 435 unified() 436 .use(markdown) 437 .use(gfm) 438 .use(remark2rehype, { allowDangerousHtml: true }) 439 .use(raw) 440 .use(htmlStringify) 441 .processSync(toc).toString() + 442 '</details>'; 443 } else { 444 file.toc = '<!-- TOC -->'; 445 } 446 }; 447} 448 449const notAlphaNumerics = /[^a-z0-9]+/g; 450const edgeUnderscores = /^_+|_+$/g; 451const notAlphaStart = /^[^a-z]/; 452function getId(text, idCounters) { 453 text = text.toLowerCase() 454 .replace(notAlphaNumerics, '_') 455 .replace(edgeUnderscores, '') 456 .replace(notAlphaStart, '_$&'); 457 if (idCounters[text] !== undefined) { 458 return `${text}_${++idCounters[text]}`; 459 } 460 idCounters[text] = 0; 461 return text; 462} 463 464function altDocs(filename, docCreated, versions) { 465 const [, docCreatedMajor, docCreatedMinor] = docCreated.map(Number); 466 const host = 'https://nodejs.org'; 467 468 const getHref = (versionNum) => 469 `${host}/docs/latest-v${versionNum}/api/${filename}.html`; 470 471 const wrapInListItem = (version) => 472 `<li><a href="${getHref(version.num)}">${version.num}${version.lts ? ' <b>LTS</b>' : ''}</a></li>`; 473 474 function isDocInVersion(version) { 475 const [versionMajor, versionMinor] = version.num.split('.').map(Number); 476 if (docCreatedMajor > versionMajor) return false; 477 if (docCreatedMajor < versionMajor) return true; 478 if (Number.isNaN(versionMinor)) return true; 479 return docCreatedMinor <= versionMinor; 480 } 481 482 const list = versions.filter(isDocInVersion).map(wrapInListItem).join('\n'); 483 484 return list ? ` 485 <li class="version-picker"> 486 <a href="#">View another version <span>▼</span></a> 487 <ol class="version-picker">${list}</ol> 488 </li> 489 ` : ''; 490} 491 492function editOnGitHub(filename) { 493 return `<li class="edit_on_github"><a href="https://github.com/nodejs/node/edit/master/doc/api/${filename}.md">Edit on GitHub</a></li>`; 494} 495