// 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 ) ;
}