diff --git a/Makefile b/Makefile index 82eb9435a2af02..3fd6d974ed74c1 100644 --- a/Makefile +++ b/Makefile @@ -649,15 +649,22 @@ out/doc/api/assets/%: doc/api_assets/% out/doc/api/assets run-npm-ci = $(PWD)/$(NPM) ci LINK_DATA = out/doc/apilinks.json +VERSIONS_DATA = out/doc/previous-versions.json gen-api = tools/doc/generate.js --node-version=$(FULLVERSION) \ - --apilinks=$(LINK_DATA) $< --output-directory=out/doc/api + --apilinks=$(LINK_DATA) $< --output-directory=out/doc/api \ + --versions-file=$(VERSIONS_DATA) gen-apilink = tools/doc/apilinks.js $(LINK_DATA) $(wildcard lib/*.js) $(LINK_DATA): $(wildcard lib/*.js) tools/doc/apilinks.js $(call available-node, $(gen-apilink)) +# Regenerate previous versions data if the current version changes +$(VERSIONS_DATA): CHANGELOG.md src/node_version.h tools/doc/versions.js + $(call available-node, tools/doc/versions.js $@) + out/doc/api/%.json out/doc/api/%.html: doc/api/%.md tools/doc/generate.js \ - tools/doc/html.js tools/doc/json.js tools/doc/apilinks.js | $(LINK_DATA) + tools/doc/html.js tools/doc/json.js tools/doc/apilinks.js \ + $(VERSIONS_DATA) | $(LINK_DATA) $(call available-node, $(gen-api)) out/doc/api/all.html: $(apidocs_html) tools/doc/allhtml.js \ diff --git a/test/doctool/test-doctool-html.js b/test/doctool/test-doctool-html.js index 703a7dcd21acb0..b128a379d9e27c 100644 --- a/test/doctool/test-doctool-html.js +++ b/test/doctool/test-doctool-html.js @@ -22,7 +22,7 @@ const remark2rehype = require('remark-rehype'); const raw = require('rehype-raw'); const htmlStringify = require('rehype-stringify'); -function toHTML({ input, filename, nodeVersion }, cb) { +function toHTML({ input, filename, nodeVersion, versions }) { const content = unified() .use(markdown) .use(html.firstHeader) @@ -34,10 +34,7 @@ function toHTML({ input, filename, nodeVersion }, cb) { .use(htmlStringify) .processSync(input); - html.toHTML( - { input, content, filename, nodeVersion }, - cb - ); + return html.toHTML({ input, content, filename, nodeVersion, versions }); } // Test data is a list of objects with two properties. @@ -102,28 +99,32 @@ const testData = [ ]; const spaces = /\s/g; +const versions = [ + { num: '10.x', lts: true }, + { num: '9.x' }, + { num: '8.x' }, + { num: '7.x' }, + { num: '6.x' }, + { num: '5.x' }, + { num: '4.x' }, + { num: '0.12.x' }, + { num: '0.10.x' }]; testData.forEach(({ file, html }) => { // Normalize expected data by stripping whitespace. const expected = html.replace(spaces, ''); - readFile(file, 'utf8', common.mustCall((err, input) => { + readFile(file, 'utf8', common.mustCall(async (err, input) => { assert.ifError(err); - toHTML( - { - input: input, - filename: 'foo', - nodeVersion: process.version, - }, - common.mustCall((err, output) => { - assert.ifError(err); + const output = toHTML({ input: input, + filename: 'foo', + nodeVersion: process.version, + versions: versions }); - const actual = output.replace(spaces, ''); - // Assert that the input stripped of all whitespace contains the - // expected markup. - assert(actual.includes(expected), - `ACTUAL: ${actual}\nEXPECTED: ${expected}`); - }) - ); + const actual = output.replace(spaces, ''); + // Assert that the input stripped of all whitespace contains the + // expected markup. + assert(actual.includes(expected), + `ACTUAL: ${actual}\nEXPECTED: ${expected}`); })); }); diff --git a/test/doctool/test-doctool-versions.js b/test/doctool/test-doctool-versions.js new file mode 100644 index 00000000000000..5673cc79402df7 --- /dev/null +++ b/test/doctool/test-doctool-versions.js @@ -0,0 +1,74 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const tmpdir = require('../common/tmpdir'); +const util = require('util'); + +const debuglog = util.debuglog('test'); +const versionsTool = path.resolve(__dirname, '../../tools/doc/versions.js'); + +// At the time of writing these are the minimum expected versions. +// New versions of Node.js do not have to be explicitly added here. +const expected = [ + '12.x', + '11.x', + '10.x', + '9.x', + '8.x', + '7.x', + '6.x', + '5.x', + '4.x', + '0.12.x', + '0.10.x', +]; + +tmpdir.refresh(); +const versionsFile = path.join(tmpdir.path, 'versions.json'); +debuglog(`${process.execPath} ${versionsTool} ${versionsFile}`); +const opts = { cwd: tmpdir.path, encoding: 'utf8' }; +const cp = spawnSync(process.execPath, [ versionsTool, versionsFile ], opts); +debuglog(cp.stderr); +debuglog(cp.stdout); +assert.strictEqual(cp.stdout, ''); +assert.strictEqual(cp.signal, null); +assert.strictEqual(cp.status, 0); +const versions = JSON.parse(fs.readFileSync(versionsFile)); +debuglog(versions); + +// Coherence checks for each returned version. +for (const version of versions) { + const tested = util.inspect(version); + const parts = version.num.split('.'); + const expectedLength = parts[0] === '0' ? 3 : 2; + assert.strictEqual(parts.length, expectedLength, + `'num' from ${tested} should be '.x'.`); + assert.strictEqual(parts[parts.length - 1], 'x', + `'num' from ${tested} doesn't end in '.x'.`); + const isEvenRelease = Number.parseInt(parts[expectedLength - 2]) % 2 === 0; + const hasLtsProperty = version.hasOwnProperty('lts'); + if (hasLtsProperty) { + // Odd-numbered versions of Node.js are never LTS. + assert.ok(isEvenRelease, `${tested} should not be an 'lts' release.`); + assert.ok(version.lts, `'lts' from ${tested} should 'true'.`); + } +} + +// Check that the minimum number of versions were returned. +// Later versions are allowed, but not checked for here (they were checked +// above). +// Also check for the previous semver major -- From master this will be the +// most recent major release. +const thisMajor = Number.parseInt(process.versions.node.split('.')[0]); +const prevMajorString = `${thisMajor - 1}.x`; +if (!expected.includes(prevMajorString)) { + expected.unshift(prevMajorString); +} +for (const version of expected) { + assert.ok(versions.find((x) => x.num === version), + `Did not find entry for '${version}' in ${util.inspect(versions)}`); +} diff --git a/tools/doc/generate.js b/tools/doc/generate.js index dd213a35a6bbc4..3127023511e248 100644 --- a/tools/doc/generate.js +++ b/tools/doc/generate.js @@ -40,6 +40,7 @@ let filename = null; let nodeVersion = null; let outputDir = null; let apilinks = {}; +let versions = {}; args.forEach(function(arg) { if (!arg.startsWith('--')) { @@ -55,6 +56,13 @@ args.forEach(function(arg) { throw new Error(`${linkFile} is empty`); } apilinks = JSON.parse(data); + } else if (arg.startsWith('--versions-file=')) { + const versionsFile = arg.replace(/^--versions-file=/, ''); + const data = fs.readFileSync(versionsFile, 'utf8'); + if (!data.trim()) { + throw new Error(`${versionsFile} is empty`); + } + versions = JSON.parse(data); } }); @@ -67,7 +75,7 @@ if (!filename) { } -fs.readFile(filename, 'utf8', (er, input) => { +fs.readFile(filename, 'utf8', async (er, input) => { if (er) throw er; const content = unified() @@ -84,15 +92,11 @@ fs.readFile(filename, 'utf8', (er, input) => { const basename = path.basename(filename, '.md'); - html.toHTML( - { input, content, filename, nodeVersion }, - (err, html) => { - const target = path.join(outputDir, `${basename}.html`); - if (err) throw err; - fs.writeFileSync(target, html); - } - ); + const myHtml = html.toHTML({ input, content, filename, nodeVersion, + versions }); + const htmlTarget = path.join(outputDir, `${basename}.html`); + fs.writeFileSync(htmlTarget, myHtml); - const target = path.join(outputDir, `${basename}.json`); - fs.writeFileSync(target, JSON.stringify(content.json, null, 2)); + const jsonTarget = path.join(outputDir, `${basename}.json`); + fs.writeFileSync(jsonTarget, JSON.stringify(content.json, null, 2)); }); diff --git a/tools/doc/html.js b/tools/doc/html.js index c9abae3818e771..c357d3a408ea96 100644 --- a/tools/doc/html.js +++ b/tools/doc/html.js @@ -62,7 +62,7 @@ const gtocHTML = unified() const templatePath = path.join(docPath, 'template.html'); const template = fs.readFileSync(templatePath, 'utf8'); -function toHTML({ input, content, filename, nodeVersion }, cb) { +function toHTML({ input, content, filename, nodeVersion, versions }) { filename = path.basename(filename, '.md'); const id = filename.replace(/\W+/g, '-'); @@ -80,13 +80,13 @@ function toHTML({ input, content, filename, nodeVersion }, cb) { const docCreated = input.match( //); if (docCreated) { - HTML = HTML.replace('__ALTDOCS__', altDocs(filename, docCreated)); + HTML = HTML.replace('__ALTDOCS__', altDocs(filename, docCreated, versions)); } else { console.error(`Failed to add alternative version links to ${filename}`); HTML = HTML.replace('__ALTDOCS__', ''); } - cb(null, HTML); + return HTML; } // Set the section name based on the first header. Default to 'Index'. @@ -380,22 +380,9 @@ function getId(text, idCounters) { return text; } -function altDocs(filename, docCreated) { +function altDocs(filename, docCreated, versions) { const [, docCreatedMajor, docCreatedMinor] = docCreated.map(Number); const host = 'https://nodejs.org'; - const versions = [ - { num: '12.x' }, - { num: '11.x' }, - { num: '10.x', lts: true }, - { num: '9.x' }, - { num: '8.x', lts: true }, - { num: '7.x' }, - { num: '6.x' }, - { num: '5.x' }, - { num: '4.x' }, - { num: '0.12.x' }, - { num: '0.10.x' } - ]; const getHref = (versionNum) => `${host}/docs/latest-v${versionNum}/api/${filename}.html`; diff --git a/tools/doc/versions.js b/tools/doc/versions.js new file mode 100644 index 00000000000000..52f5648ecae92f --- /dev/null +++ b/tools/doc/versions.js @@ -0,0 +1,80 @@ +'use strict'; + +const { readFileSync, writeFileSync } = require('fs'); +const path = require('path'); +const srcRoot = path.join(__dirname, '..', '..'); + +const isRelease = () => { + const re = /#define NODE_VERSION_IS_RELEASE 0/; + const file = path.join(srcRoot, 'src', 'node_version.h'); + return !re.test(readFileSync(file, { encoding: 'utf8' })); +}; + +const getUrl = (url) => { + return new Promise((resolve, reject) => { + const https = require('https'); + const request = https.get(url, { timeout: 30000 }, (response) => { + if (response.statusCode !== 200) { + reject(new Error( + `Failed to get ${url}, status code ${response.statusCode}`)); + } + response.setEncoding('utf8'); + let body = ''; + response.on('aborted', () => reject()); + response.on('data', (data) => body += data); + response.on('end', () => resolve(body)); + }); + request.on('error', (err) => reject(err)); + request.on('timeout', () => request.abort()); + }); +}; + +const kNoInternet = !!process.env.NODE_TEST_NO_INTERNET; +const outFile = (process.argv.length > 2 ? process.argv[2] : undefined); + +async function versions() { + // The CHANGELOG.md on release branches may not reference newer semver + // majors of Node.js so fetch and parse the version from the master branch. + const url = + 'https://raw.githubusercontent.com/nodejs/node/master/CHANGELOG.md'; + let changelog; + const file = path.join(srcRoot, 'CHANGELOG.md'); + if (kNoInternet) { + changelog = readFileSync(file, { encoding: 'utf8' }); + } else { + try { + changelog = await getUrl(url); + } catch (e) { + // Fail if this is a release build, otherwise fallback to local files. + if (isRelease()) { + throw e; + } else { + console.warn(`Unable to retrieve ${url}. Falling back to ${file}.`); + changelog = readFileSync(file, { encoding: 'utf8' }); + } + } + } + const ltsRE = /Long Term Support/i; + const versionRE = /\* \[Node\.js ([0-9.]+)\]\S+ (.*)\r?\n/g; + const _versions = []; + let match; + while ((match = versionRE.exec(changelog)) != null) { + const entry = { num: `${match[1]}.x` }; + if (ltsRE.test(match[2])) { + entry.lts = true; + } + _versions.push(entry); + } + return _versions; +} + +versions().then((v) => { + if (outFile) { + writeFileSync(outFile, JSON.stringify(v)); + } else { + console.log(JSON.stringify(v)); + } +}).catch((err) => { + console.error(err); + process.exit(1); +});