• 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';
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>&#x25bc;</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