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 common = require('./common.js'); 25const fs = require('fs'); 26const unified = require('unified'); 27const find = require('unist-util-find'); 28const visit = require('unist-util-visit'); 29const markdown = require('remark-parse'); 30const remark2rehype = require('remark-rehype'); 31const raw = require('rehype-raw'); 32const htmlStringify = require('rehype-stringify'); 33const path = require('path'); 34const typeParser = require('./type-parser.js'); 35const { highlight, getLanguage } = require('highlight.js'); 36 37module.exports = { 38 toHTML, firstHeader, preprocessText, preprocessElements, buildToc 39}; 40 41const docPath = path.resolve(__dirname, '..', '..', 'doc'); 42 43// Add class attributes to index navigation links. 44function navClasses() { 45 return (tree) => { 46 visit(tree, { type: 'element', tagName: 'a' }, (node) => { 47 node.properties.class = 'nav-' + 48 node.properties.href.replace('.html', '').replace(/\W+/g, '-'); 49 }); 50 }; 51} 52 53const gtocPath = path.join(docPath, 'api', 'index.md'); 54const gtocMD = fs.readFileSync(gtocPath, 'utf8').replace(/^<!--.*?-->/gms, ''); 55const gtocHTML = unified() 56 .use(markdown) 57 .use(remark2rehype, { allowDangerousHtml: true }) 58 .use(raw) 59 .use(navClasses) 60 .use(htmlStringify) 61 .processSync(gtocMD).toString(); 62 63const templatePath = path.join(docPath, 'template.html'); 64const template = fs.readFileSync(templatePath, 'utf8'); 65 66function toHTML({ input, content, filename, nodeVersion, versions }) { 67 filename = path.basename(filename, '.md'); 68 69 const id = filename.replace(/\W+/g, '-'); 70 71 let HTML = template.replace('__ID__', id) 72 .replace(/__FILENAME__/g, filename) 73 .replace('__SECTION__', content.section) 74 .replace(/__VERSION__/g, nodeVersion) 75 .replace('__TOC__', content.toc) 76 .replace('__GTOC__', gtocHTML.replace( 77 `class="nav-${id}"`, `class="nav-${id} active"`)) 78 .replace('__EDIT_ON_GITHUB__', editOnGitHub(filename)) 79 .replace('__CONTENT__', content.toString()); 80 81 const docCreated = input.match( 82 /<!--\s*introduced_in\s*=\s*v([0-9]+)\.([0-9]+)\.[0-9]+\s*-->/); 83 if (docCreated) { 84 HTML = HTML.replace('__ALTDOCS__', altDocs(filename, docCreated, versions)); 85 } else { 86 console.error(`Failed to add alternative version links to ${filename}`); 87 HTML = HTML.replace('__ALTDOCS__', ''); 88 } 89 90 return HTML; 91} 92 93// Set the section name based on the first header. Default to 'Index'. 94function firstHeader() { 95 return (tree, file) => { 96 const heading = find(tree, { type: 'heading' }); 97 98 if (heading && heading.children.length) { 99 const recursiveTextContent = (node) => 100 node.value || node.children.map(recursiveTextContent).join(''); 101 file.section = recursiveTextContent(heading); 102 } else { 103 file.section = 'Index'; 104 } 105 }; 106} 107 108// Handle general body-text replacements. 109// For example, link man page references to the actual page. 110function preprocessText({ nodeVersion }) { 111 return (tree) => { 112 visit(tree, null, (node) => { 113 if (common.isSourceLink(node.value)) { 114 const [path] = node.value.match(/(?<=<!-- source_link=).*(?= -->)/); 115 node.value = `<p><strong>Source Code:</strong> <a href="https://github.com/nodejs/node/blob/${nodeVersion}/${path}">${path}</a></p>`; 116 } else if (node.type === 'text' && node.value) { 117 const value = linkJsTypeDocs(linkManPages(node.value)); 118 if (value !== node.value) { 119 node.type = 'html'; 120 node.value = value; 121 } 122 } 123 }); 124 }; 125} 126 127// Syscalls which appear in the docs, but which only exist in BSD / macOS. 128const BSD_ONLY_SYSCALLS = new Set(['lchmod']); 129const MAN_PAGE = /(^|\s)([a-z.]+)\((\d)([a-z]?)\)/gm; 130 131// Handle references to man pages, eg "open(2)" or "lchmod(2)". 132// Returns modified text, with such refs replaced with HTML links, for example 133// '<a href="http://man7.org/linux/man-pages/man2/open.2.html">open(2)</a>'. 134function linkManPages(text) { 135 return text.replace( 136 MAN_PAGE, (match, beginning, name, number, optionalCharacter) => { 137 // Name consists of lowercase letters, 138 // number is a single digit with an optional lowercase letter. 139 const displayAs = `<code>${name}(${number}${optionalCharacter})</code>`; 140 141 if (BSD_ONLY_SYSCALLS.has(name)) { 142 return `${beginning}<a href="https://www.freebsd.org/cgi/man.cgi` + 143 `?query=${name}&sektion=${number}">${displayAs}</a>`; 144 } 145 146 return `${beginning}<a href="http://man7.org/linux/man-pages/man${number}` + 147 `/${name}.${number}${optionalCharacter}.html">${displayAs}</a>`; 148 }); 149} 150 151const TYPE_SIGNATURE = /\{[^}]+\}/g; 152function linkJsTypeDocs(text) { 153 const parts = text.split('`'); 154 155 // Handle types, for example the source Markdown might say 156 // "This argument should be a {number} or {string}". 157 for (let i = 0; i < parts.length; i += 2) { 158 const typeMatches = parts[i].match(TYPE_SIGNATURE); 159 if (typeMatches) { 160 typeMatches.forEach((typeMatch) => { 161 parts[i] = parts[i].replace(typeMatch, typeParser.toLink(typeMatch)); 162 }); 163 } 164 } 165 166 return parts.join('`'); 167} 168 169// Preprocess headers, stability blockquotes, and YAML blocks. 170function preprocessElements({ filename }) { 171 return (tree) => { 172 const STABILITY_RE = /(.*:)\s*(\d)([\s\S]*)/; 173 let headingIndex = -1; 174 let heading = null; 175 176 visit(tree, null, (node, index) => { 177 if (node.type === 'heading') { 178 headingIndex = index; 179 heading = node; 180 } else if (node.type === 'code') { 181 if (!node.lang) { 182 console.warn( 183 `No language set in ${filename}, ` + 184 `line ${node.position.start.line}`); 185 } 186 const language = (node.lang || '').split(' ')[0]; 187 const highlighted = getLanguage(language) ? 188 highlight(language, node.value).value : 189 node.value; 190 node.type = 'html'; 191 node.value = '<pre>' + 192 `<code class = 'language-${node.lang}'>` + 193 highlighted + 194 '</code></pre>'; 195 } else if (node.type === 'html' && common.isYAMLBlock(node.value)) { 196 node.value = parseYAML(node.value); 197 198 } else if (node.type === 'blockquote') { 199 const paragraph = node.children[0].type === 'paragraph' && 200 node.children[0]; 201 const text = paragraph && paragraph.children[0].type === 'text' && 202 paragraph.children[0]; 203 if (text && text.value.includes('Stability:')) { 204 const [, prefix, number, explication] = 205 text.value.match(STABILITY_RE); 206 207 const isStabilityIndex = 208 index - 2 === headingIndex || // General. 209 index - 3 === headingIndex; // With api_metadata block. 210 211 if (heading && isStabilityIndex) { 212 heading.stability = number; 213 headingIndex = -1; 214 heading = null; 215 } 216 217 // Do not link to the section we are already in. 218 const noLinking = filename.includes('documentation') && 219 heading !== null && heading.children[0].value === 'Stability Index'; 220 221 // Collapse blockquote and paragraph into a single node 222 node.type = 'paragraph'; 223 node.children.shift(); 224 node.children.unshift(...paragraph.children); 225 226 // Insert div with prefix and number 227 node.children.unshift({ 228 type: 'html', 229 value: `<div class="api_stability api_stability_${number}">` + 230 (noLinking ? '' : 231 '<a href="documentation.html#documentation_stability_index">') + 232 `${prefix} ${number}${noLinking ? '' : '</a>'}` 233 .replace(/\n/g, ' ') 234 }); 235 236 // Remove prefix and number from text 237 text.value = explication; 238 239 // close div 240 node.children.push({ type: 'html', value: '</div>' }); 241 } 242 } 243 }); 244 }; 245} 246 247function parseYAML(text) { 248 const meta = common.extractAndParseYAML(text); 249 let result = '<div class="api_metadata">\n'; 250 251 const added = { description: '' }; 252 const deprecated = { description: '' }; 253 const removed = { description: '' }; 254 255 if (meta.added) { 256 added.version = meta.added.join(', '); 257 added.description = `<span>Added in: ${added.version}</span>`; 258 } 259 260 if (meta.deprecated) { 261 deprecated.version = meta.deprecated.join(', '); 262 deprecated.description = 263 `<span>Deprecated since: ${deprecated.version}</span>`; 264 } 265 266 if (meta.removed) { 267 removed.version = meta.removed.join(', '); 268 removed.description = `<span>Removed in: ${removed.version}</span>`; 269 } 270 271 if (meta.changes.length > 0) { 272 if (added.description) meta.changes.push(added); 273 if (deprecated.description) meta.changes.push(deprecated); 274 if (removed.description) meta.changes.push(removed); 275 276 meta.changes.sort((a, b) => versionSort(a.version, b.version)); 277 278 result += '<details class="changelog"><summary>History</summary>\n' + 279 '<table>\n<tr><th>Version</th><th>Changes</th></tr>\n'; 280 281 meta.changes.forEach((change) => { 282 const description = unified() 283 .use(markdown) 284 .use(remark2rehype, { allowDangerousHtml: true }) 285 .use(raw) 286 .use(htmlStringify) 287 .processSync(change.description).toString(); 288 289 const version = common.arrify(change.version).join(', '); 290 291 result += `<tr><td>${version}</td>\n` + 292 `<td>${description}</td></tr>\n`; 293 }); 294 295 result += '</table>\n</details>\n'; 296 } else { 297 result += `${added.description}${deprecated.description}` + 298 `${removed.description}\n`; 299 } 300 301 if (meta.napiVersion) { 302 result += `<span>N-API version: ${meta.napiVersion.join(', ')}</span>\n`; 303 } 304 305 result += '</div>'; 306 return result; 307} 308 309function minVersion(a) { 310 return common.arrify(a).reduce((min, e) => { 311 return !min || versionSort(min, e) < 0 ? e : min; 312 }); 313} 314 315const numberRe = /^\d*/; 316function versionSort(a, b) { 317 a = minVersion(a).trim(); 318 b = minVersion(b).trim(); 319 let i = 0; // Common prefix length. 320 while (i < a.length && i < b.length && a[i] === b[i]) i++; 321 a = a.substr(i); 322 b = b.substr(i); 323 return +b.match(numberRe)[0] - +a.match(numberRe)[0]; 324} 325 326function buildToc({ filename, apilinks }) { 327 return (tree, file) => { 328 const idCounters = Object.create(null); 329 let toc = ''; 330 let depth = 0; 331 332 visit(tree, null, (node) => { 333 if (node.type !== 'heading') return; 334 335 if (node.depth - depth > 1) { 336 throw new Error( 337 `Inappropriate heading level:\n${JSON.stringify(node)}` 338 ); 339 } 340 341 depth = node.depth; 342 const realFilename = path.basename(filename, '.md'); 343 const headingText = file.contents.slice( 344 node.children[0].position.start.offset, 345 node.position.end.offset).trim(); 346 const id = getId(`${realFilename}_${headingText}`, idCounters); 347 348 const hasStability = node.stability !== undefined; 349 toc += ' '.repeat((depth - 1) * 2) + 350 (hasStability ? `* <span class="stability_${node.stability}">` : '* ') + 351 `<a href="#${id}">${headingText}</a>${hasStability ? '</span>' : ''}\n`; 352 353 let anchor = 354 `<span><a class="mark" href="#${id}" id="${id}">#</a></span>`; 355 356 if (realFilename === 'errors' && headingText.startsWith('ERR_')) { 357 anchor += `<span><a class="mark" href="#${headingText}" ` + 358 `id="${headingText}">#</a></span>`; 359 } 360 361 const api = headingText.replace(/^.*:\s+/, '').replace(/\(.*/, ''); 362 if (apilinks[api]) { 363 anchor = `<a class="srclink" href=${apilinks[api]}>[src]</a>${anchor}`; 364 } 365 366 node.children.push({ type: 'html', value: anchor }); 367 }); 368 369 file.toc = unified() 370 .use(markdown) 371 .use(remark2rehype, { allowDangerousHtml: true }) 372 .use(raw) 373 .use(htmlStringify) 374 .processSync(toc).toString(); 375 }; 376} 377 378const notAlphaNumerics = /[^a-z0-9]+/g; 379const edgeUnderscores = /^_+|_+$/g; 380const notAlphaStart = /^[^a-z]/; 381function getId(text, idCounters) { 382 text = text.toLowerCase() 383 .replace(notAlphaNumerics, '_') 384 .replace(edgeUnderscores, '') 385 .replace(notAlphaStart, '_$&'); 386 if (idCounters[text] !== undefined) { 387 return `${text}_${++idCounters[text]}`; 388 } 389 idCounters[text] = 0; 390 return text; 391} 392 393function altDocs(filename, docCreated, versions) { 394 const [, docCreatedMajor, docCreatedMinor] = docCreated.map(Number); 395 const host = 'https://nodejs.org'; 396 397 const getHref = (versionNum) => 398 `${host}/docs/latest-v${versionNum}/api/${filename}.html`; 399 400 const wrapInListItem = (version) => 401 `<li><a href="${getHref(version.num)}">${version.num}` + 402 `${version.lts ? ' <b>LTS</b>' : ''}</a></li>`; 403 404 function isDocInVersion(version) { 405 const [versionMajor, versionMinor] = version.num.split('.').map(Number); 406 if (docCreatedMajor > versionMajor) return false; 407 if (docCreatedMajor < versionMajor) return true; 408 if (Number.isNaN(versionMinor)) return true; 409 return docCreatedMinor <= versionMinor; 410 } 411 412 const list = versions.filter(isDocInVersion).map(wrapInListItem).join('\n'); 413 414 return list ? ` 415 <li class="version-picker"> 416 <a href="#">View another version <span>▼</span></a> 417 <ol class="version-picker">${list}</ol> 418 </li> 419 ` : ''; 420} 421 422// eslint-disable-next-line max-len 423const githubLogo = '<span class="github_icon"><svg height="16" width="16" viewBox="0 0 16.1 16.1" fill="currentColor"><path d="M8 0a8 8 0 0 0-2.5 15.6c.4 0 .5-.2.5-.4v-1.5c-2 .4-2.5-.5-2.7-1 0-.1-.5-.9-.8-1-.3-.2-.7-.6 0-.6.6 0 1 .6 1.2.8.7 1.2 1.9 1 2.4.7 0-.5.2-.9.5-1-1.8-.3-3.7-1-3.7-4 0-.9.3-1.6.8-2.2 0-.2-.3-1 .1-2 0 0 .7-.3 2.2.7a7.4 7.4 0 0 1 4 0c1.5-1 2.2-.8 2.2-.8.5 1.1.2 2 .1 2.1.5.6.8 1.3.8 2.2 0 3-1.9 3.7-3.6 4 .3.2.5.7.5 1.4v2.2c0 .2.1.5.5.4A8 8 0 0 0 16 8a8 8 0 0 0-8-8z"/></svg></span>'; 424function editOnGitHub(filename) { 425 return `<li class="edit_on_github"><a href="https://github.com/nodejs/node/edit/master/doc/api/${filename}.md">${githubLogo}Edit on GitHub</a></li>`; 426} 427