Skip to content

Commit

Permalink
Add doc generator (#10)
Browse files Browse the repository at this point in the history
Add a command to generate a Typescript class documentation in our format

kuzdoc generate:js path/to/class
  • Loading branch information
Aschen authored Jan 19, 2022
1 parent e0a25c0 commit f7db5e6
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 1,018 deletions.
1,098 changes: 82 additions & 1,016 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"fs-extra": "^10.0.0",
"inquirer": "^7.3.3",
"listr": "^0.14.3",
"ts-morph": "^13.0.2",
"tslib": "^2.1.0",
"yaml": "^1.10.0"
},
Expand Down
47 changes: 47 additions & 0 deletions src/commands/generate/js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import path from 'path'
import { Command, flags } from '@oclif/command'
import 'ts-node/register'

import { ClassExtractor } from '../../lib/generator/javascript/ClassExtractor'
import { MarkdownFormatter } from '../../lib/generator/javascript/MarkdownFormatter'

const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'lib', 'generator', 'javascript', 'templates')
export default class GenerateJs extends Command {
static description = `Generate the documentation of a class written in Typescript.`

static flags = {
path: flags.string({
description: 'Directory to write the doc',
default: 'generated'
}),
help: flags.help({ char: 'h' }),
}

static args = [
{ name: 'filePath', description: 'File containing the class to generate the doc', required: true },
]

async run() {
const { args, flags } = this.parse(GenerateJs)

this.log(`Generating documentation for the class defined in "${args.filePath}"`)

const formatter = new MarkdownFormatter(flags.path, TEMPLATES_DIR)

const extractor = new ClassExtractor(args.filePath)

let className
extractor.on('class', classInfo => {
className = classInfo.name
formatter.onClass(classInfo)
})

extractor.on('method', methodInfo => {
this.log(`Creates doc for method "${methodInfo.name}"`)
})

extractor.extract()

this.log(`Documentation for class "${className}" created`)
}
}
150 changes: 150 additions & 0 deletions src/lib/generator/javascript/ClassExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { EventEmitter } from 'stream';

import { ClassDeclaration, JSDoc, MethodDeclaration, Project, SyntaxKind } from 'ts-morph';

export type InfoMethod = {
name: string;
signature: string;
description: string;
args: InfoMethodArgs[];
internal: boolean;
returnType: string;
returnTypeRaw: string;
scope: 'public' | 'private' | 'protected';
}

export type InfoMethodArgs = {
name: string;
description: string;
type: string;
typeRaw: string;
optional: boolean;
}

export type InfoClass = {
name: string;
description: string;
internal: boolean;
methods: InfoMethod[];
}

export class ClassExtractor extends EventEmitter {
private filePath: string;

public info: InfoClass | null = null;

public methods: InfoMethod[] = [];

constructor(filePath: string) {
super();

this.filePath = filePath;
}

extract() {
const project = new Project();

for (const classDeclaration of project.addSourceFileAtPath(this.filePath).getClasses()) {
this.extractClass(classDeclaration);
}
}

private extractClass(classDeclaration: ClassDeclaration) {
try {
const name = classDeclaration.getName() as string;

const { description, internal } = this.extractClassProperties(classDeclaration);

const methods = this.extractMethods(classDeclaration);

this.info = { name, description, internal, methods };

this.emit('class', this.info);
}
catch (error) {
throw new Error(`Cannot extract class "${classDeclaration.getName()}": ${error.message}${error.stack}`);
}
}

private extractClassProperties(classDeclaration: ClassDeclaration) {
const jsDoc = classDeclaration.getChildrenOfKind(SyntaxKind.JSDocComment)[0];

if (!jsDoc) {
console.log(`[warn] Class "${classDeclaration.getName()}" does not have js doc comment`);
return { internal: false, description: '' }
}

const internal = Boolean(jsDoc.getTags().find(tag => tag.getTagName() === 'internal'));

const description = this.formatText(jsDoc.getComment());

return { internal, description }
}

private extractMethods(classDeclaration: ClassDeclaration): InfoMethod[] {
const methods: InfoMethod[] = [];

for (const method of classDeclaration.getMethods()) {
const { description, args, internal } = this.extractMethodProperties(classDeclaration, method)

const name = method.getName();

const scope = method.getScope();

const returnTypeRaw = method.getReturnType().getText();
const returnType = returnTypeRaw.replace(/import\(.*\)\./, '');
const signature = `${scope === 'public' ? '' : `${scope} `}${name} (${args.map(arg => `${arg.name}${arg.optional ? '?' : ''}: ${arg.type}`).join(', ')}): ${returnType}`;

const methodInfo = { name, signature, description, args, internal, scope, returnType, returnTypeRaw };

methods.push(methodInfo);

this.emit('method', methodInfo);
}

return methods;
}

private extractMethodProperties(classDeclaration: ClassDeclaration, method: MethodDeclaration) {
const jsDoc = method.getChildrenOfKind(SyntaxKind.JSDocComment)[0];

if (!jsDoc) {
console.log(`[warn] Method "${classDeclaration.getName()}.${method.getName()}" does not have js doc comment`);
return { description: '', args: [], internal: false }
}

const description = this.formatText(jsDoc.getComment());

const args = this.getMethodArgs(jsDoc);

const internal = Boolean(jsDoc.getTags().find(tag => tag.getTagName() === 'internal'));

return { description, args, internal }
}

private getMethodArgs(jsDoc: JSDoc): InfoMethodArgs[] {
const args: InfoMethodArgs[] = jsDoc.getTags()
.filter(tag => tag.getTagName() === 'param')
.map((tag: any) => {
const typeRaw = tag.getSymbol().getValueDeclaration().getType().getText();
const type = typeRaw.replace(/import\(.*\)\./, '');

return {
name: tag.getSymbol().getEscapedName(),
description: this.formatText(tag.getComment()),
type,
typeRaw,
// If someone find better than this ugly hack I'm in!
optional: Boolean(
tag.getSymbol().getValueDeclaration()['_compilerNode']['questionToken']
|| tag.getSymbol().getValueDeclaration()['_compilerNode']['initalizer']),
};
});

return args;
}

private formatText(text: any) {
return text.replace('\n\n', '\n') as string;
}
}
104 changes: 104 additions & 0 deletions src/lib/generator/javascript/MarkdownFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import path from 'path';

import _ from 'lodash';

import { InfoClass, InfoMethod } from './ClassExtractor';

function upFirst(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}

function kebabCase(string: string): string {
return string
// get all lowercase letters that are near to uppercase ones
.replace(/([a-z])([A-Z])/g, '$1-$2')
// replace all spaces and low dash
.replace(/[\s_]+/g, '-')
.toLowerCase();
}

function contextualizeKeys(context: string, values: Record<string, any>) {
const obj: Record<string, any> = {};

for (const [key, value] of Object.entries(values)) {
obj[`${context}${upFirst(key)}`] = value;
}

return obj;
}

/**
* "You who enter here, abandon all hope"
*
* If you really want to be completely disgusted about Typescript then I can advise you to start a project with Oclif.
* The typescript compiler is so strict, it's keeping bothering you with complete useless nonsense.
*
* Additionally, it's simply bugged and will throw error when there is none, like here.
* If I try to declare those as class attribute Oclif is complaining :)
*/
let outputDir: any;
let templateDir: any;
let baseDir: any;

export class MarkdownFormatter {
constructor(outputDirArg: string, templateDirArg: string) {
outputDir = outputDirArg;
templateDir = templateDirArg;
}

onClass(classInfo: InfoClass) {
if (classInfo.internal) {
return;
}

baseDir = path.join(outputDir, kebabCase(classInfo.name));

const rootIndex = this.renderTemplate(
['class', 'index.tpl.md'],
contextualizeKeys('class', classInfo));

this.writeFile(['index.md'], rootIndex);

const introduction = this.renderTemplate(
['class', 'introduction', 'index.tpl.md'],
contextualizeKeys('class', classInfo));

this.writeFile(['introduction', 'index.md'], introduction);

for (const method of classInfo.methods) {
this.onMethod(classInfo, method);
}
}

onMethod(classInfo: InfoClass, infoMethod: InfoMethod) {
if (infoMethod.internal) {
return;
}

const method = this.renderTemplate(
['class', 'method', 'index.tpl.md'],
{
...contextualizeKeys('method', infoMethod),
...contextualizeKeys('class', classInfo),
});

this.writeFile([infoMethod.name, 'index.md'], method);
}

private writeFile(paths: string[], content: string) {
const fullPath = path.join(baseDir as string, ...paths.map(p => kebabCase(p)));

mkdirSync(path.dirname(fullPath), { recursive: true });

writeFileSync(fullPath, content);
}

private renderTemplate(paths: string[], values: Record<string, any> = {}): string {
const fullPath = path.join(templateDir, ...paths);

const compiled = _.template(readFileSync(fullPath, { encoding: 'utf-8' }));

return compiled(values);
}
}
6 changes: 6 additions & 0 deletions src/lib/generator/javascript/templates/class/index.tpl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
code: true
type: branch
title: <%= className %>
description: <%= className %> class documentation
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
code: false
type: page
title: Introduction
description: <%= className %> class
order: 0
---

# <%= className %>

<SinceBadge version="auto-version" />

<%= classDescription %>
23 changes: 23 additions & 0 deletions src/lib/generator/javascript/templates/class/method/index.tpl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
code: true
type: page
title: <%= methodName %>
description: <%= className %> <%= methodName %> method
---

# <%= methodName %>

<SinceBadge version="auto-version" />

<%= methodDescription %>

## Arguments

```js
<%= methodSignature %>
```

| Argument | Type | Description |
|----------|------|-------------|
<% _.forEach(methodArgs, function(arg) { %>| `<%= arg.name %>` | <pre><%= arg.type %></pre> | <%= arg.description %> |
<% }); %>
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"module": "commonjs",
"outDir": "lib",
"rootDir": "src",
"strict": true,
"target": "es2017",
"esModuleInterop": true
"esModuleInterop": true,

},
"include": ["src/**/*"]
}

0 comments on commit f7db5e6

Please sign in to comment.