-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmarkdown-from-jsdoc.ts
151 lines (136 loc) · 5.42 KB
/
markdown-from-jsdoc.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import type {Preset} from '.'
import {parse} from '@babel/parser'
import traverse from '@babel/traverse'
import {Node} from '@babel/types'
import * as lodash from 'lodash'
import * as os from 'os'
import * as path from 'path'
/**
* Convert jsdoc for an es export from a javascript/typescript file to markdown.
*
* ##### Example
*
* `<!-- codegen:start {preset: markdownFromJsdoc, source: src/foo.ts, export: bar} -->`
*
* @param source {string} relative file path containing the export with jsdoc that should be copied to markdown
* @param export {string} the name of the export
* @param headerLevel {1|2|3|4|5} Determines if the export will correspond to a H1, H2, H3, H4 or H5. Nested headers will increment from this value. @default 4
*/
export const markdownFromJsdoc: Preset<{source: string; export?: string; headerLevel?: number}> = ({
meta,
options: {source: relativeFile, export: exportName, headerLevel = 4},
dependencies: {fs},
}) => {
const targetFile = path.join(path.dirname(meta.filename), relativeFile)
const sourceCode = fs.readFileSync(targetFile).toString()
const ast = parse(sourceCode, {sourceType: 'module', plugins: ['typescript']})
const idable = {} as Record<string, Node>
traverse(ast, {
ExportNamedDeclaration({node: decl}) {
switch (decl.declaration?.type) {
case 'ClassDeclaration': {
idable[decl.declaration.id.name] = decl as Node
for (const node of decl.declaration.body.body) {
if (node.type === 'ClassPrivateMethod' || node.type === 'ClassPrivateProperty') continue
if ('key' in node && 'name' in node.key) {
idable[`${decl.declaration.id.name}: ${node.key.name}`] = node as Node
}
}
break
}
case 'VariableDeclaration': {
for (const d of decl.declaration.declarations) {
if (d.id.type !== 'Identifier') continue
idable[d.id.name] = decl as Node
}
break
}
default: {
if (decl.declaration && 'id' in decl.declaration && 'name' in decl.declaration.id!) {
idable[decl.declaration.id.name] = decl as Node
}
}
}
},
})
const h = (n: number) => '#'.repeat(n)
const formatNode = (name: string, node: Node) => {
const parts = name.split(': ')
const level = headerLevel + parts.length - 1
const contentUpToExport = node.leadingComments?.map(c => c.value).join('\n\n') || ''
const jsdoc = contentUpToExport
.split('\n')
.map(line => line.trim())
.map(line => {
return line
.replace(/^\/\*\*$/, '') // clean up: /**
.replaceAll(/^\* /g, '') // clean up: * blah
.replaceAll(/^\*$/g, '') // clean up: *
.replace(/^\*\/$/, '') // clean up */
})
.join(os.EOL)
const sections = `\n@description ${jsdoc}`
.split(/\n@/)
.map(section => section.trim() + ' ')
.filter(Boolean)
.map((section, index) => {
const firstSpace = section.search(/\s/)
return {type: section.slice(0, firstSpace), index, content: section.slice(firstSpace).trim()}
})
.filter(s => s.content)
const formatted = sections.map((sec, i, arr) => {
if (sec.type === 'example') {
return [h(level + 1) + ' Example', '', '```typescript', sec.content, '```'].join(os.EOL)
}
if (sec.type === 'param') {
const allParams = arr.filter(other => other.type === sec.type)
if (sec !== allParams[0]) {
return null
}
const rows = allParams.map((p): [string, string] => {
const whitespaceMatch = /\s/.exec(p.content)
const firstSpace = whitespaceMatch ? whitespaceMatch.index : p.content.length
const rowName = p.content.slice(0, firstSpace)
const description = p.content
.slice(firstSpace + 1)
.trim()
.replaceAll(/\r?\n/g, '<br />')
return [rowName, description]
})
const headers: [string, string] = ['name', 'description']
const nameSize = lodash.max([headers, ...rows].map(r => r[0].length))!
const descSize = lodash.max([headers, ...rows].map(r => r[1].length))!
const pad = (tuple: [string, string], padding = ' ') =>
`|${tuple[0].padEnd(nameSize, padding)}|${tuple[1].padEnd(descSize, padding)}|`
return [
h(level + 1) + ' Params', // breakme
'',
pad(headers),
pad(['', ''], '-'),
...rows.map(tuple => pad(tuple)),
].join(os.EOL)
}
if (sec.type === 'description') {
// line breaks that run into letters aren't respected by jsdoc, so shouldn't be in markdown either
return sec.content.replaceAll(/\r?\n\s*([A-Za-z])/g, ' $1')
}
if (sec.type === 'see') {
return null
}
return [`${h(level + 1)} ${lodash.startCase(sec.type)}`, sec.content].join(os.EOL + os.EOL)
})
return [`${h(level)} [${parts.at(-1)}](./${relativeFile}#L${node.loc?.start.line || 1})`, ...formatted]
.filter(Boolean)
.join(os.EOL + os.EOL)
}
const blocks = Object.entries(idable)
.map(([name, node]) => {
if (exportName && name !== exportName) return ''
return formatNode(name, node)
})
.filter(Boolean)
if (blocks.length === 0 && exportName) {
throw new Error(`Couldn't find export in ${targetFile} with jsdoc called ${exportName}`)
}
return blocks.join('\n\n')
}