Skip to content

Commit 50100f3

Browse files
rubystargos
authored andcommitted
tools: Include links to source code in documentation
Parse source code using acorn; extracting exports. When producing documentation, match exports to headers. When a match is found, add a [src] link. This first commit handles simple exported classes and functions, and does so without requiring any changes to the source code or markdown. Subsequent commits will attempt to match more headers, and some of these changes are likely to require changes to the source code and/or markdown. PR-URL: #22405 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Refael Ackermann <[email protected]> Reviewed-By: Vse Mozhet Byt <[email protected]>
1 parent d3bb741 commit 50100f3

File tree

13 files changed

+252
-4
lines changed

13 files changed

+252
-4
lines changed

Makefile

+6-1
Original file line numberDiff line numberDiff line change
@@ -645,10 +645,15 @@ out/doc/api/assets/%: doc/api_assets/% out/doc/api/assets
645645
run-npm-ci = $(PWD)/$(NPM) ci
646646

647647
gen-api = tools/doc/generate.js --node-version=$(FULLVERSION) \
648+
--apilinks=out/apilinks.json \
648649
--analytics=$(DOCS_ANALYTICS) $< --output-directory=out/doc/api
650+
gen-apilink = tools/doc/apilinks.js $(wildcard lib/*.js) > $@
651+
652+
out/apilinks.json: $(wildcard lib/*.js) tools/doc/apilinks.js
653+
$(call available-node, $(gen-apilink))
649654

650655
out/doc/api/%.json out/doc/api/%.html: doc/api/%.md tools/doc/generate.js \
651-
tools/doc/html.js tools/doc/json.js
656+
tools/doc/html.js tools/doc/json.js | out/apilinks.json
652657
$(call available-node, $(gen-api))
653658

654659
out/doc/api/all.html: $(apidocs_html) tools/doc/allhtml.js

doc/api_assets/style.css

+5
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@ h2, h3, h4, h5 {
283283
padding-right: 40px;
284284
}
285285

286+
.srclink {
287+
float: right;
288+
font-size: smaller;
289+
}
290+
286291
h1 span, h2 span, h3 span, h4 span {
287292
position: absolute;
288293
display: block;

test/doctool/test-apilinks.js

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use strict';
2+
3+
require('../common');
4+
const fixtures = require('../common/fixtures');
5+
const fs = require('fs');
6+
const assert = require('assert');
7+
const path = require('path');
8+
const { execFileSync } = require('child_process');
9+
10+
const script = path.join(__dirname, '..', '..', 'tools', 'doc', 'apilinks.js');
11+
12+
const apilinks = fixtures.path('apilinks');
13+
fs.readdirSync(apilinks).forEach((fixture) => {
14+
if (!fixture.endsWith('.js')) return;
15+
const file = path.join(apilinks, fixture);
16+
17+
const expectedContent = fs.readFileSync(file + 'on', 'utf8');
18+
19+
const output = execFileSync(
20+
process.execPath,
21+
[script, file],
22+
{ encoding: 'utf-8' }
23+
);
24+
25+
const expectedLinks = JSON.parse(expectedContent);
26+
const actualLinks = JSON.parse(output);
27+
28+
for (const [k, v] of Object.entries(expectedLinks)) {
29+
assert.ok(k in actualLinks, `link not found: ${k}`);
30+
assert.ok(actualLinks[k].endsWith('/' + v),
31+
`link ${actualLinks[k]} expected to end with ${v}`);
32+
delete actualLinks[k];
33+
}
34+
35+
assert.strictEqual(
36+
Object.keys(actualLinks).length, 0,
37+
`unexpected links returned ${JSON.stringify(actualLinks)}`
38+
);
39+
});

test/doctool/test-doctool-html.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function toHTML({ input, filename, nodeVersion, analytics }, cb) {
2828
.use(html.firstHeader)
2929
.use(html.preprocessText)
3030
.use(html.preprocessElements, { filename })
31-
.use(html.buildToc, { filename })
31+
.use(html.buildToc, { filename, apilinks: {} })
3232
.use(remark2rehype, { allowDangerousHTML: true })
3333
.use(raw)
3434
.use(htmlStringify)

test/fixtures/apilinks/buffer.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use strict';
2+
3+
// Buffer instance methods are exported as 'buf'.
4+
5+
function Buffer() {
6+
}
7+
8+
Buffer.prototype.instanceMethod = function() {}
9+
10+
module.exports = {
11+
Buffer
12+
};

test/fixtures/apilinks/buffer.json

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"buffer.Buffer": "buffer.js#L5",
3+
"buf.instanceMethod": "buffer.js#L8"
4+
}

test/fixtures/apilinks/mod.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use strict';
2+
3+
// A module may export one or more methods.
4+
5+
function foo() {
6+
}
7+
8+
9+
module.exports = {
10+
foo
11+
};

test/fixtures/apilinks/mod.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"mod.foo": "mod.js#L5"
3+
}

test/fixtures/apilinks/prototype.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use strict';
2+
3+
// An exported class using classic prototype syntax.
4+
5+
function Class() {
6+
}
7+
8+
Class.classMethod = function() {}
9+
Class.prototype.instanceMethod = function() {}
10+
11+
module.exports = {
12+
Class
13+
};

test/fixtures/apilinks/prototype.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"prototype.Class": "prototype.js#L5",
3+
"Class.classMethod": "prototype.js#L8",
4+
"class.instanceMethod": "prototype.js#L9"
5+
}

tools/doc/apilinks.js

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict';
2+
3+
// Scan API sources for definitions.
4+
//
5+
// Note the output is produced based on a world class parser, adherence to
6+
// conventions, and a bit of guess work. Examples:
7+
//
8+
// * We scan for top level module.exports statements, and determine what
9+
// is exported by looking at the source code only (i.e., we don't do
10+
// an eval). If exports include `Foo`, it probably is a class, whereas
11+
// if what is exported is `constants` it probably is prefixed by the
12+
// basename of the source file (e.g., `zlib`), unless that source file is
13+
// `buffer.js`, in which case the name is just `buf`. unless the constant
14+
// is `kMaxLength`, in which case it is `buffer`.
15+
//
16+
// * We scan for top level definitions for those exports, handling
17+
// most common cases (e.g., `X.prototype.foo =`, `X.foo =`,
18+
// `function X(...) {...}`). Over time, we expect to handle more
19+
// cases (example: ES2015 class definitions).
20+
21+
const acorn = require('../../deps/acorn');
22+
const fs = require('fs');
23+
const path = require('path');
24+
const child_process = require('child_process');
25+
26+
// Run a command, capturing stdout, ignoring errors.
27+
function execSync(command) {
28+
try {
29+
return child_process.execSync(
30+
command,
31+
{ stdio: ['ignore', null, 'ignore'] }
32+
).toString().trim();
33+
} catch {
34+
return '';
35+
}
36+
}
37+
38+
// Determine orign repo and tag (or hash) of the most recent commit.
39+
const local_branch = execSync('git name-rev --name-only HEAD');
40+
const tracking_remote = execSync(`git config branch.${local_branch}.remote`);
41+
const remote_url = execSync(`git config remote.${tracking_remote}.url`);
42+
const repo = (remote_url.match(/(\w+\/\w+)\.git\r?\n?$/) ||
43+
['', 'nodejs/node'])[1];
44+
45+
const hash = execSync('git log -1 --pretty=%H') || 'master';
46+
const tag = execSync(`git describe --contains ${hash}`).split('\n')[0] || hash;
47+
48+
// Extract definitions from each file specified.
49+
const definition = {};
50+
process.argv.slice(2).forEach((file) => {
51+
const basename = path.basename(file, '.js');
52+
53+
// Parse source.
54+
const source = fs.readFileSync(file, 'utf8');
55+
const ast = acorn.parse(source, { ecmaVersion: 10, locations: true });
56+
const program = ast.body;
57+
58+
// Scan for exports.
59+
const exported = { constructors: [], identifiers: [] };
60+
program.forEach((statement) => {
61+
if (statement.type !== 'ExpressionStatement') return;
62+
const expr = statement.expression;
63+
if (expr.type !== 'AssignmentExpression') return;
64+
65+
let lhs = expr.left;
66+
if (expr.left.object.type === 'MemberExpression') lhs = lhs.object;
67+
if (lhs.type !== 'MemberExpression') return;
68+
if (lhs.object.name !== 'module') return;
69+
if (lhs.property.name !== 'exports') return;
70+
71+
let rhs = expr.right;
72+
while (rhs.type === 'AssignmentExpression') rhs = rhs.right;
73+
74+
if (rhs.type === 'NewExpression') {
75+
exported.constructors.push(rhs.callee.name);
76+
} else if (rhs.type === 'ObjectExpression') {
77+
rhs.properties.forEach((property) => {
78+
if (property.value.type === 'Identifier') {
79+
exported.identifiers.push(property.value.name);
80+
if (/^[A-Z]/.test(property.value.name[0])) {
81+
exported.constructors.push(property.value.name);
82+
}
83+
}
84+
});
85+
} else if (rhs.type === 'Identifier') {
86+
exported.identifiers.push(rhs.name);
87+
}
88+
});
89+
90+
// Scan for definitions matching those exports; currently supports:
91+
//
92+
// ClassName.foo = ...;
93+
// ClassName.prototype.foo = ...;
94+
// function Identifier(...) {...};
95+
//
96+
const link = `https://github.com/${repo}/blob/${tag}/` +
97+
path.relative('.', file).replace(/\\/g, '/');
98+
99+
program.forEach((statement) => {
100+
if (statement.type === 'ExpressionStatement') {
101+
const expr = statement.expression;
102+
if (expr.type !== 'AssignmentExpression') return;
103+
if (expr.left.type !== 'MemberExpression') return;
104+
105+
let object;
106+
if (expr.left.object.type === 'MemberExpression') {
107+
if (expr.left.object.property.name !== 'prototype') return;
108+
object = expr.left.object.object;
109+
} else if (expr.left.object.type === 'Identifier') {
110+
object = expr.left.object;
111+
} else {
112+
return;
113+
}
114+
115+
if (!exported.constructors.includes(object.name)) return;
116+
117+
let objectName = object.name;
118+
if (expr.left.object.type === 'MemberExpression') {
119+
objectName = objectName.toLowerCase();
120+
if (objectName === 'buffer') objectName = 'buf';
121+
}
122+
123+
let name = expr.left.property.name;
124+
if (expr.left.computed) {
125+
name = `${objectName}[${name}]`;
126+
} else {
127+
name = `${objectName}.${name}`;
128+
}
129+
130+
definition[name] = `${link}#L${statement.loc.start.line}`;
131+
} else if (statement.type === 'FunctionDeclaration') {
132+
const name = statement.id.name;
133+
if (!exported.identifiers.includes(name)) return;
134+
if (basename.startsWith('_')) return;
135+
definition[`${basename}.${name}`] =
136+
`${link}#L${statement.loc.start.line}`;
137+
}
138+
});
139+
});
140+
141+
console.log(JSON.stringify(definition, null, 2));

tools/doc/generate.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ let filename = null;
4040
let nodeVersion = null;
4141
let analytics = null;
4242
let outputDir = null;
43+
let apilinks = {};
4344

4445
args.forEach(function(arg) {
4546
if (!arg.startsWith('--')) {
@@ -50,6 +51,10 @@ args.forEach(function(arg) {
5051
analytics = arg.replace(/^--analytics=/, '');
5152
} else if (arg.startsWith('--output-directory=')) {
5253
outputDir = arg.replace(/^--output-directory=/, '');
54+
} else if (arg.startsWith('--apilinks=')) {
55+
apilinks = JSON.parse(
56+
fs.readFileSync(arg.replace(/^--apilinks=/, ''), 'utf8')
57+
);
5358
}
5459
});
5560

@@ -71,7 +76,7 @@ fs.readFile(filename, 'utf8', (er, input) => {
7176
.use(json.jsonAPI, { filename })
7277
.use(html.firstHeader)
7378
.use(html.preprocessElements, { filename })
74-
.use(html.buildToc, { filename })
79+
.use(html.buildToc, { filename, apilinks })
7580
.use(remark2rehype, { allowDangerousHTML: true })
7681
.use(raw)
7782
.use(htmlStringify)

tools/doc/html.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ function versionSort(a, b) {
329329
return +b.match(numberRe)[0] - +a.match(numberRe)[0];
330330
}
331331

332-
function buildToc({ filename }) {
332+
function buildToc({ filename, apilinks }) {
333333
return (tree, file) => {
334334
const startIncludeRefRE = /^\s*<!-- \[start-include:(.+)\] -->\s*$/;
335335
const endIncludeRefRE = /^\s*<!-- \[end-include:.+\] -->\s*$/;
@@ -376,6 +376,11 @@ function buildToc({ filename }) {
376376
`id="${headingText}">#</a></span>`;
377377
}
378378

379+
const api = headingText.replace(/^.*:\s+/, '').replace(/\(.*/, '');
380+
if (apilinks[api]) {
381+
anchor = `<a class="srclink" href=${apilinks[api]}>[src]</a>${anchor}`;
382+
}
383+
379384
node.children.push({ type: 'html', value: anchor });
380385
});
381386

0 commit comments

Comments
 (0)