// 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 input globs"]; // put required options in the required variable export const required = [ ["--output ", "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); }