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