@avelonjs/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/package.json +58 -0
- package/src/bin.ts +5 -0
- package/src/commands.ts +399 -0
- package/src/docs-check.ts +415 -0
- package/src/driver-scaffold.ts +524 -0
- package/src/generate.ts +306 -0
- package/src/index.ts +42 -0
- package/src/io.ts +65 -0
- package/src/menu.ts +53 -0
- package/src/tui.tsx +77 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, extname, isAbsolute, join, relative } from 'node:path'
|
|
4
|
+
import * as ts from 'typescript'
|
|
5
|
+
|
|
6
|
+
import { writeln, type ReeveIO } from './io'
|
|
7
|
+
|
|
8
|
+
/** One docs:check failure. */
|
|
9
|
+
export interface DocsFinding {
|
|
10
|
+
/** Machine-readable kind. */
|
|
11
|
+
kind: 'missing-section' | 'unparsed-code-block' | 'missing-export' | 'missing-readme'
|
|
12
|
+
/** Human-readable explanation. */
|
|
13
|
+
message: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Result of linting one package README. */
|
|
17
|
+
export interface DocsCheckResult {
|
|
18
|
+
/** Package directory that was checked. */
|
|
19
|
+
root: string
|
|
20
|
+
/** True when there are no findings. */
|
|
21
|
+
ok: boolean
|
|
22
|
+
/** Failures, empty when `ok`. */
|
|
23
|
+
findings: readonly DocsFinding[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REQUIRED_SECTIONS = ['Installation', 'Basic Usage', 'Method Reference', 'Testing'] as const
|
|
27
|
+
|
|
28
|
+
const PARSEABLE_LANGUAGES = new Set([
|
|
29
|
+
'ts',
|
|
30
|
+
'tsx',
|
|
31
|
+
'typescript',
|
|
32
|
+
'mts',
|
|
33
|
+
'cts',
|
|
34
|
+
'js',
|
|
35
|
+
'jsx',
|
|
36
|
+
'javascript',
|
|
37
|
+
'mjs',
|
|
38
|
+
'cjs',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
interface WaiverSet {
|
|
42
|
+
sections: Set<string>
|
|
43
|
+
exports: Set<string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Lints one package README against house-style rules. */
|
|
47
|
+
export async function checkPackageDocs(root: string): Promise<DocsCheckResult> {
|
|
48
|
+
const findings: DocsFinding[] = []
|
|
49
|
+
const readmePath = join(root, 'README.md')
|
|
50
|
+
let readme: string
|
|
51
|
+
try {
|
|
52
|
+
readme = await readFile(readmePath, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
root,
|
|
56
|
+
ok: false,
|
|
57
|
+
findings: [
|
|
58
|
+
{
|
|
59
|
+
kind: 'missing-readme',
|
|
60
|
+
message: `README.md is missing. A package without docs is incomplete.`,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const waivers = parseWaivers(readme)
|
|
67
|
+
const headings = markdownHeadings(readme)
|
|
68
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
69
|
+
if (headings.has(section) || waivers.sections.has(section.toLowerCase())) continue
|
|
70
|
+
findings.push({
|
|
71
|
+
kind: 'missing-section',
|
|
72
|
+
message: `Required section '${section}' is missing. Add the heading or an explicit waiver.`,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!hasIntroduction(readme)) {
|
|
77
|
+
findings.push({
|
|
78
|
+
kind: 'missing-section',
|
|
79
|
+
message:
|
|
80
|
+
'Introduction is missing. Put what the package does and when to reach for it under the title, in under five sentences.',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const block of markdownCodeBlocks(readme)) {
|
|
85
|
+
const finding = parseCodeBlock(block.language, block.code, block.index)
|
|
86
|
+
if (finding !== undefined) findings.push(finding)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const documented = documentedExports(readme)
|
|
90
|
+
const exported = await collectPackageExports(root)
|
|
91
|
+
for (const name of exported) {
|
|
92
|
+
if (isExportDocumented(name, documented) || waivers.exports.has(name)) continue
|
|
93
|
+
findings.push({
|
|
94
|
+
kind: 'missing-export',
|
|
95
|
+
message: `Public export '${name}' is missing from the Method Reference table.`,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { root, ok: findings.length === 0, findings }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Runs `docs:check` for one directory or every workspace package. */
|
|
103
|
+
export async function runDocsCheck(argv: readonly string[], io: ReeveIO): Promise<number> {
|
|
104
|
+
const workspace = argv.includes('--workspace')
|
|
105
|
+
const targets = argv.filter((arg) => !arg.startsWith('-'))
|
|
106
|
+
const roots = workspace
|
|
107
|
+
? await workspacePackages(io.cwd)
|
|
108
|
+
: targets.length > 0
|
|
109
|
+
? targets.map((target) => (isAbsolute(target) ? target : join(io.cwd, target)))
|
|
110
|
+
: await defaultRoots(io.cwd)
|
|
111
|
+
|
|
112
|
+
if (roots.length === 0) {
|
|
113
|
+
writeln(io, 'No packages to check.')
|
|
114
|
+
return 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let failed = 0
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
const result = await checkPackageDocs(root)
|
|
120
|
+
const label = relative(io.cwd, root) || root
|
|
121
|
+
if (result.ok) {
|
|
122
|
+
writeln(io, `${label}: docs:check clean`)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
failed += 1
|
|
126
|
+
writeln(io, `${label}: docs:check failed`)
|
|
127
|
+
for (const finding of result.findings) writeln(io, ` ${finding.kind}: ${finding.message}`)
|
|
128
|
+
}
|
|
129
|
+
return failed === 0 ? 0 : 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function defaultRoots(cwd: string): Promise<string[]> {
|
|
133
|
+
if (await isPackageRoot(cwd)) return [cwd]
|
|
134
|
+
const nested = await workspacePackages(cwd)
|
|
135
|
+
return nested.length > 0 ? nested : [cwd]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function workspacePackages(cwd: string): Promise<string[]> {
|
|
139
|
+
const dir = join(cwd, 'packages')
|
|
140
|
+
try {
|
|
141
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
142
|
+
const roots: string[] = []
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isDirectory()) continue
|
|
145
|
+
const root = join(dir, entry.name)
|
|
146
|
+
if (await isPackageRoot(root)) roots.push(root)
|
|
147
|
+
}
|
|
148
|
+
return roots.sort()
|
|
149
|
+
} catch {
|
|
150
|
+
return []
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function isPackageRoot(root: string): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
const raw = await readFile(join(root, 'package.json'), 'utf8')
|
|
157
|
+
const parsed: unknown = JSON.parse(raw)
|
|
158
|
+
return typeof parsed === 'object' && parsed !== null
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseWaivers(readme: string): WaiverSet {
|
|
165
|
+
const sections = new Set<string>()
|
|
166
|
+
const exports = new Set<string>()
|
|
167
|
+
const pattern = /<!--\s*docs:check-waiver:\s*(section|export)\s+([^>]+?)\s*-->/gi
|
|
168
|
+
for (const match of readme.matchAll(pattern)) {
|
|
169
|
+
const kind = match[1]?.toLowerCase()
|
|
170
|
+
const name = match[2]?.trim()
|
|
171
|
+
if (kind === undefined || name === undefined) continue
|
|
172
|
+
if (kind === 'section') sections.add(name.toLowerCase())
|
|
173
|
+
else exports.add(name)
|
|
174
|
+
}
|
|
175
|
+
return { sections, exports }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function markdownHeadings(readme: string): Set<string> {
|
|
179
|
+
const headings = new Set<string>()
|
|
180
|
+
for (const line of readme.split(/\r?\n/)) {
|
|
181
|
+
const match = /^##\s+(.+?)\s*$/.exec(line)
|
|
182
|
+
const title = match?.[1]?.trim()
|
|
183
|
+
if (title !== undefined) headings.add(title)
|
|
184
|
+
}
|
|
185
|
+
return headings
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hasIntroduction(readme: string): boolean {
|
|
189
|
+
const withoutFence = readme.replace(/^```[\s\S]*?^```/gm, '')
|
|
190
|
+
const match = /^#\s+.+\n+([\s\S]*?)(?:\n##\s|\s*$)/.exec(withoutFence)
|
|
191
|
+
const intro = match?.[1]?.trim() ?? ''
|
|
192
|
+
return intro.length > 0 && !intro.startsWith('#')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface CodeBlock {
|
|
196
|
+
language: string
|
|
197
|
+
code: string
|
|
198
|
+
index: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function markdownCodeBlocks(readme: string): CodeBlock[] {
|
|
202
|
+
const blocks: CodeBlock[] = []
|
|
203
|
+
const pattern = /^```([^\n`]*)\n([\s\S]*?)^```/gm
|
|
204
|
+
let index = 0
|
|
205
|
+
for (const match of readme.matchAll(pattern)) {
|
|
206
|
+
index += 1
|
|
207
|
+
blocks.push({
|
|
208
|
+
language: (match[1] ?? '').trim().toLowerCase(),
|
|
209
|
+
code: match[2] ?? '',
|
|
210
|
+
index,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
return blocks
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseCodeBlock(language: string, code: string, index: number): DocsFinding | undefined {
|
|
217
|
+
if (language === 'json') {
|
|
218
|
+
try {
|
|
219
|
+
JSON.parse(code)
|
|
220
|
+
return undefined
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'unparsed-code-block',
|
|
224
|
+
message: `Code block ${String(index)} (json) does not parse: ${errorMessage(error)}`,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (language.length === 0 || !PARSEABLE_LANGUAGES.has(language)) return undefined
|
|
229
|
+
const fileName = language === 'tsx' || language === 'jsx' ? 'example.tsx' : 'example.ts'
|
|
230
|
+
const transpile = ts.transpileModule(code, {
|
|
231
|
+
compilerOptions: {
|
|
232
|
+
module: ts.ModuleKind.ESNext,
|
|
233
|
+
target: ts.ScriptTarget.ES2022,
|
|
234
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
235
|
+
},
|
|
236
|
+
reportDiagnostics: true,
|
|
237
|
+
fileName,
|
|
238
|
+
})
|
|
239
|
+
const errors = (transpile.diagnostics ?? []).filter(
|
|
240
|
+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
|
241
|
+
)
|
|
242
|
+
if (errors.length === 0) return undefined
|
|
243
|
+
return {
|
|
244
|
+
kind: 'unparsed-code-block',
|
|
245
|
+
message: `Code block ${String(index)} (${language}) does not parse: ${formatDiagnostic(errors[0])}`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatDiagnostic(diagnostic: ts.Diagnostic | undefined): string {
|
|
250
|
+
if (diagnostic === undefined) return 'syntax error'
|
|
251
|
+
return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function documentedExports(readme: string): Set<string> {
|
|
255
|
+
const names = new Set<string>()
|
|
256
|
+
const start = readme.search(/^##\s+Method Reference\s*$/m)
|
|
257
|
+
if (start === -1) return names
|
|
258
|
+
const rest = readme.slice(start)
|
|
259
|
+
const endMatch = rest.slice(1).search(/^##\s+/m)
|
|
260
|
+
const table = endMatch === -1 ? rest : rest.slice(0, endMatch + 1)
|
|
261
|
+
for (const line of table.split(/\r?\n/)) {
|
|
262
|
+
if (!line.trim().startsWith('|')) continue
|
|
263
|
+
const cells = line
|
|
264
|
+
.trim()
|
|
265
|
+
.split('|')
|
|
266
|
+
.slice(1, -1)
|
|
267
|
+
.map((cell) => cell.trim())
|
|
268
|
+
const first = cells[0]
|
|
269
|
+
if (first === undefined || first.length === 0) continue
|
|
270
|
+
if (/^[-:]+$/.test(first.replace(/\s/g, ''))) continue
|
|
271
|
+
if (/^method/i.test(first)) continue
|
|
272
|
+
const token = first.replace(/^`+|`+$/g, '').trim()
|
|
273
|
+
if (token.length === 0) continue
|
|
274
|
+
names.add(token)
|
|
275
|
+
}
|
|
276
|
+
return names
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isExportDocumented(name: string, documented: Set<string>): boolean {
|
|
280
|
+
if (documented.has(name)) return true
|
|
281
|
+
for (const entry of documented) {
|
|
282
|
+
if (entry === name) return true
|
|
283
|
+
if (entry.startsWith(`${name}.`)) return true
|
|
284
|
+
}
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function collectPackageExports(root: string): Promise<string[]> {
|
|
289
|
+
const entry = await packageEntry(root)
|
|
290
|
+
if (entry === undefined) return []
|
|
291
|
+
const names = new Set<string>()
|
|
292
|
+
await collectFromFile(entry, root, names, new Set())
|
|
293
|
+
return [...names].sort()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function packageEntry(root: string): Promise<string | undefined> {
|
|
297
|
+
try {
|
|
298
|
+
const raw = await readFile(join(root, 'package.json'), 'utf8')
|
|
299
|
+
const parsed: unknown = JSON.parse(raw)
|
|
300
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
301
|
+
const exportsField = Reflect.get(parsed, 'exports')
|
|
302
|
+
const main = Reflect.get(parsed, 'main')
|
|
303
|
+
const fromExports = resolveExportField(exportsField)
|
|
304
|
+
const relative = fromExports ?? (typeof main === 'string' ? main : 'src/index.ts')
|
|
305
|
+
return join(root, relative)
|
|
306
|
+
} catch {
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveExportField(exportsField: unknown): string | undefined {
|
|
312
|
+
if (typeof exportsField === 'string') return exportsField
|
|
313
|
+
if (typeof exportsField !== 'object' || exportsField === null) return undefined
|
|
314
|
+
const dot = Reflect.get(exportsField, '.')
|
|
315
|
+
if (typeof dot === 'string') return dot
|
|
316
|
+
if (typeof dot === 'object' && dot !== null) {
|
|
317
|
+
const importPath = Reflect.get(dot, 'import')
|
|
318
|
+
const defaultPath = Reflect.get(dot, 'default')
|
|
319
|
+
if (typeof importPath === 'string') return importPath
|
|
320
|
+
if (typeof defaultPath === 'string') return defaultPath
|
|
321
|
+
}
|
|
322
|
+
return undefined
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function collectFromFile(
|
|
326
|
+
file: string,
|
|
327
|
+
root: string,
|
|
328
|
+
names: Set<string>,
|
|
329
|
+
seen: Set<string>,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
if (seen.has(file)) return
|
|
332
|
+
seen.add(file)
|
|
333
|
+
let sourceText: string
|
|
334
|
+
try {
|
|
335
|
+
sourceText = await readFile(file, 'utf8')
|
|
336
|
+
} catch {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const kind = extname(file) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
340
|
+
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, kind)
|
|
341
|
+
for (const statement of source.statements) {
|
|
342
|
+
if (ts.isExportDeclaration(statement)) {
|
|
343
|
+
const moduleSpecifier = statement.moduleSpecifier
|
|
344
|
+
const resolved =
|
|
345
|
+
moduleSpecifier !== undefined && ts.isStringLiteral(moduleSpecifier)
|
|
346
|
+
? resolveRelative(file, root, moduleSpecifier.text)
|
|
347
|
+
: undefined
|
|
348
|
+
if (statement.exportClause === undefined) {
|
|
349
|
+
if (resolved !== undefined) await collectFromFile(resolved, root, names, seen)
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (ts.isNamespaceExport(statement.exportClause)) {
|
|
353
|
+
names.add(statement.exportClause.name.text)
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
for (const element of statement.exportClause.elements) {
|
|
357
|
+
names.add(element.name.text)
|
|
358
|
+
}
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
if (ts.isExportAssignment(statement)) {
|
|
362
|
+
names.add('default')
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
const mods = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
|
|
366
|
+
const exported =
|
|
367
|
+
mods?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
368
|
+
if (!exported) continue
|
|
369
|
+
if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) {
|
|
370
|
+
names.add(statement.name.text)
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (ts.isClassDeclaration(statement) && statement.name !== undefined) {
|
|
374
|
+
names.add(statement.name.text)
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
|
378
|
+
names.add(statement.name.text)
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
if (ts.isEnumDeclaration(statement)) {
|
|
382
|
+
names.add(statement.name.text)
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
if (ts.isVariableStatement(statement)) {
|
|
386
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
387
|
+
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function resolveRelative(fromFile: string, root: string, specifier: string): string | undefined {
|
|
394
|
+
if (!specifier.startsWith('.')) return undefined
|
|
395
|
+
const base = join(dirname(fromFile), specifier)
|
|
396
|
+
const candidates = [
|
|
397
|
+
base,
|
|
398
|
+
`${base}.ts`,
|
|
399
|
+
`${base}.tsx`,
|
|
400
|
+
`${base}.mts`,
|
|
401
|
+
`${base}.cts`,
|
|
402
|
+
join(base, 'index.ts'),
|
|
403
|
+
join(base, 'index.tsx'),
|
|
404
|
+
]
|
|
405
|
+
for (const candidate of candidates) {
|
|
406
|
+
const rel = relative(root, candidate)
|
|
407
|
+
if (rel.startsWith('..')) continue
|
|
408
|
+
if (existsSync(candidate)) return candidate
|
|
409
|
+
}
|
|
410
|
+
return undefined
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function errorMessage(error: unknown): string {
|
|
414
|
+
return error instanceof Error ? error.message : String(error)
|
|
415
|
+
}
|