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