• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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">&#x25ba;</span><span class="expanded-arrow">&#x25bc;</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">&#x25ba;</span><span class="expanded-arrow">&#x25bc;</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">&#x25ba;</span><span class="expanded-arrow">&#x25bc;</span>
579        Table of contents
580      </a>
581
582      <div class="picker">${content.tocPicker}</div>
583    </li>
584  `;
585}
586