An even more educational version of the Bandolier for Learn JS the Hard Way.
https://learnjsthehardway.com/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
345 lines
8.9 KiB
345 lines
8.9 KiB
// you may not need all of these but they come up a lot
|
|
import fs from "fs";
|
|
import assert from "assert";
|
|
import logging from '../lib/logging.js';
|
|
import { mkdir_to, glob } from "../lib/builderator.js";
|
|
import { create_renderer } from "../lib/docgen.js";
|
|
import path from "path";
|
|
import slugify from "slugify";
|
|
import * as acorn from "acorn";
|
|
import * as acorn_walk from "acorn-walk";
|
|
|
|
const log = logging.create(import.meta.url);
|
|
|
|
export const description = "Describe your command here."
|
|
|
|
// your command uses the npm package commander's options format
|
|
export const options = [
|
|
["--quiet", "Don't output the stats at the end."],
|
|
["--readme", "README file to use as the initial page", "README.md"]
|
|
]
|
|
|
|
// example of a positional argument, it's the 1st argument to main
|
|
export const argument = ["<source...>", "source input globs"];
|
|
|
|
// put required options in the required variable
|
|
export const required = [
|
|
["--output <string>", "Save to file rather than stdout."],
|
|
]
|
|
|
|
const RENDERER = create_renderer();
|
|
const CAPS_WORDS = ["BUG", "TODO", "WARNING", "FOOTGUN", "DEPRECATED"];
|
|
const STATS = {total: 0, docs: 0, undoc: 0};
|
|
|
|
const slug = (instring) => slugify(instring, { lower: true, strict: true, trim: true});
|
|
|
|
/*
|
|
Strips 1 leading space from the comments, or the \s\* combinations
|
|
in traditional documentation comments.
|
|
|
|
If we strip more it'll mess up formatting in markdown for indentation
|
|
formats. Weirdly Remarkable seems to be able to handle leading spaces pretty
|
|
well so only need to remove one space or \s\* combinations like with
|
|
traditional comment docs.
|
|
*/
|
|
const render_comment = (comment) => {
|
|
const lines = comment.split(/\n/).map(l => l.replace(/^(\s*\*\s?|\s)/, ''));
|
|
return RENDERER.render(lines.join("\n"));
|
|
}
|
|
|
|
/* Handy function for checking things are good and aborting. */
|
|
const check = (test, fail_message) => {
|
|
if(!test) {
|
|
log.error(fail_message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const dump = (obj) => {
|
|
return JSON.stringify(obj, null, 4);
|
|
}
|
|
|
|
class ParseWalker {
|
|
constructor(comments, code) {
|
|
this.comments = comments;
|
|
this.exported = [];
|
|
this.code = code;
|
|
}
|
|
|
|
handle_class(root) {
|
|
const new_class = {
|
|
isa: "class",
|
|
slug: slug(root.declaration.id.name),
|
|
name: root.declaration.id.name,
|
|
line_start: root.loc.start.line,
|
|
methods: [],
|
|
}
|
|
|
|
acorn_walk.simple(root, {
|
|
ClassDeclaration: (cls_node) => {
|
|
assert(cls_node.id.name === new_class.name, "Name of class changed!");
|
|
new_class.range = [cls_node.start, cls_node.body.start];
|
|
this.add_export(cls_node.id, new_class);
|
|
},
|
|
|
|
MethodDefinition: (meth_node) => {
|
|
const new_method = {
|
|
isa: "method",
|
|
static: meth_node.static,
|
|
async: meth_node.value.async,
|
|
generator: meth_node.value.generator,
|
|
slug: slug(`${new_class.name}-${meth_node.key.name}`),
|
|
name: meth_node.key.name,
|
|
line_start: meth_node.loc.start.line,
|
|
range: [meth_node.start, meth_node.value.body.start],
|
|
params: this.handle_params(meth_node.value.params),
|
|
comment: this.find_comment(meth_node.loc.start.line),
|
|
}
|
|
|
|
this.has_CAPS(new_method);
|
|
|
|
new_method.code = this.slice_code(new_method.range);
|
|
|
|
this.update_stats(new_method); // methods can't go through add_export
|
|
new_class.methods.push(new_method);
|
|
}
|
|
});
|
|
}
|
|
|
|
handle_params(param_list) {
|
|
const result = [];
|
|
|
|
for(let param of param_list) {
|
|
acorn_walk.simple(param, {
|
|
Identifier: (_node) => {
|
|
result.push({isa: "identifier", name: _node.name});
|
|
},
|
|
|
|
AssignmentPattern: (_node) => {
|
|
result.push({
|
|
isa: "assignment",
|
|
name: _node.left.name,
|
|
right: {
|
|
type: _node.right.type.toLowerCase(),
|
|
raw: _node.right.raw,
|
|
value: _node.right.value,
|
|
name: _node.right.name,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/*
|
|
Used to add information when something is mentioned in the
|
|
comment like BUG, TODO, etc.
|
|
*/
|
|
has_CAPS(exp) {
|
|
if(exp.comment) {
|
|
exp.caps = CAPS_WORDS.filter(phrase => exp.comment.includes(phrase));
|
|
} else {
|
|
exp.caps = [];
|
|
}
|
|
}
|
|
|
|
update_stats(exp) {
|
|
STATS.total += 1;
|
|
|
|
if(exp.comment) {
|
|
STATS.docs += 1;
|
|
} else {
|
|
STATS.undoc += 1;
|
|
}
|
|
}
|
|
|
|
add_export(id, exp) {
|
|
exp.name = id.name;
|
|
exp.slug = exp.slug ? exp.slug : slug(id.name);
|
|
exp.line_start = id.loc.start.line;
|
|
exp.comment = this.find_comment(exp.line_start);
|
|
this.has_CAPS(exp);
|
|
exp.code = this.slice_code(exp.range);
|
|
this.exported.push(exp);
|
|
|
|
this.update_stats(exp);
|
|
}
|
|
|
|
slice_code(range) {
|
|
return this.code.slice(range[0], range[1]);
|
|
}
|
|
|
|
handle_arrow_func(id, arrow) {
|
|
this.add_export(id, {
|
|
isa: "function",
|
|
async: arrow.async,
|
|
generator: arrow.generator,
|
|
expression: arrow.expression,
|
|
range: [id.start, arrow.body.start],
|
|
params: this.handle_params(arrow.params),
|
|
});
|
|
}
|
|
|
|
handle_variable(root) {
|
|
const declare = root.declaration.declarations[0];
|
|
const id = declare.id;
|
|
const _node = declare.init;
|
|
const init_is = declare.init.type;
|
|
|
|
if(init_is === "ArrowFunctionExpression") {
|
|
this.handle_arrow_func(id, declare.init);
|
|
} else {
|
|
this.add_export(id, {
|
|
isa: _node.type.toLowerCase(),
|
|
value: _node.value,
|
|
range: declare.range,
|
|
raw: _node.raw
|
|
});
|
|
}
|
|
}
|
|
|
|
/*
|
|
Find the nearest comment to this line, giving
|
|
about 2 lines of slack.
|
|
*/
|
|
find_comment(line) {
|
|
for(let c of this.comments) {
|
|
const distance = c.end - line;
|
|
if(!c.found && distance == -1) {
|
|
c.found = true;
|
|
return render_comment(c.value);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
|
|
/*
|
|
Returns the first comment as the file's main doc comment, or undefined if there isn't one.
|
|
*/
|
|
file_comment() {
|
|
const comment = this.comments[0];
|
|
|
|
if(comment && comment.start === 1) {
|
|
// kind of a hack, but find_comment will find this now
|
|
return this.find_comment(comment.end + 1);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
handle_export(_node) {
|
|
switch(_node.declaration.type) {
|
|
case "ClassDeclaration":
|
|
this.handle_class(_node);
|
|
break;
|
|
case "VariableDeclaration": {
|
|
this.handle_variable(_node);
|
|
break;
|
|
}
|
|
default:
|
|
console.log(">>>", _node.declaration.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
const parse_source = (source) => {
|
|
const code = fs.readFileSync(source);
|
|
|
|
let comments = [];
|
|
|
|
const acorn_opts = {
|
|
sourceType: "module",
|
|
ecmaVersion: "2023",
|
|
locations: true,
|
|
sourceFile: source,
|
|
ranges: true,
|
|
onComment: comments
|
|
}
|
|
|
|
const parsed = acorn.parse(code, acorn_opts);
|
|
|
|
comments = comments.filter(c => c.type === "Block").map(c => {
|
|
return {
|
|
start: c.loc.start.line,
|
|
end: c.loc.end.line,
|
|
value: c.value,
|
|
type: "comment",
|
|
found: false,
|
|
}
|
|
});
|
|
|
|
const walker = new ParseWalker(comments, code.toString());
|
|
// acorn is stupid and they grab a reference to the functions so that _removes_
|
|
// this from the object, instead of just...calling walker.function() like a normal person
|
|
acorn_walk.simple(parsed, {
|
|
ExportNamedDeclaration: (_node) => walker.handle_export(_node),
|
|
});
|
|
|
|
let comment = walker.file_comment();
|
|
|
|
return {
|
|
// normalize to / even on windows
|
|
source: source.replaceAll("\\", "/"),
|
|
// find the first comment for the file's comment
|
|
comment,
|
|
exports: walker.exported,
|
|
orphan_comments: walker.comments.filter(c => !c.found)
|
|
};
|
|
}
|
|
|
|
const normalize_name = (fname) => {
|
|
const no_slash = fname.replaceAll("\\", "/");
|
|
|
|
if(fname.startsWith("./")) {
|
|
return no_slash.slice(2);
|
|
} else if(fname.startsWith("/")) {
|
|
return no_slash.slice(1);
|
|
} else {
|
|
return no_slash;
|
|
}
|
|
}
|
|
|
|
|
|
export const main = async (source_globs, opts) => {
|
|
const index = {};
|
|
mkdir_to(opts.output);
|
|
|
|
for(let source of source_globs) {
|
|
const source_list = glob(source);
|
|
|
|
for(let fname of source_list) {
|
|
const result = parse_source(fname);
|
|
|
|
const target = `${path.join(opts.output, fname)}.json`;
|
|
mkdir_to(target);
|
|
fs.writeFileSync(target, dump(result));
|
|
|
|
const name = normalize_name(fname);
|
|
|
|
index[name] = result.exports.map(e => {
|
|
return {isa: e.isa, name: e.name};
|
|
});
|
|
}
|
|
}
|
|
|
|
// now write the grand index
|
|
const index_name = path.join(opts.output, "index.json");
|
|
fs.writeFileSync(index_name, dump(index));
|
|
|
|
// render the README.md to the initial docs
|
|
const readme_name = path.join(opts.output, "index.html");
|
|
const md_out = RENDERER.render(fs.readFileSync(opts.readme).toString());
|
|
fs.writeFileSync(readme_name, md_out);
|
|
|
|
const percent = Math.floor(100 * STATS.docs / STATS.total);
|
|
|
|
if(!opts.quiet) {
|
|
console.log(`Total ${STATS.total}, ${percent}% documented (${STATS.docs} docs vs. ${STATS.undoc} no docs).`);
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|