checklist docs generator

This commit is contained in:
chyzman
2026-08-10 18:35:12 -04:00
parent 6d065ccfc9
commit 094514ac69
4 changed files with 370 additions and 2 deletions
+3
View File
@@ -5,6 +5,9 @@ dist
tmp
/out-tsc
# generated checklist structure dump (packages/moderation/scripts/checklist-docs.ts)
.checklist-docs
# dependencies
node_modules
+3 -1
View File
@@ -7,7 +7,8 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file src/locales/en-US/index.json --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation",
"docs:dump": "pnpx tsx scripts/checklist-docs.ts"
},
"dependencies": {
"@modrinth/assets": "workspace:*",
@@ -19,6 +20,7 @@
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@modrinth/ui": "workspace:*",
"ts-morph": "^24.0.0",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,338 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CallExpression, Node, ReturnStatement } from 'ts-morph'
import { Project, SyntaxKind } from 'ts-morph'
const __dirname = dirname(fileURLToPath(import.meta.url))
const PACKAGE_ROOT = join(__dirname, '..')
const STAGES_GLOB = join(PACKAGE_ROOT, 'src/data/stages/*.tsx')
const OUT_DIR = join(PACKAGE_ROOT, '.checklist-docs')
const FACTORY_INPUT_TYPES: Record<string, string> = {
stage: 'Stage',
toggle: 'Toggle',
toggleSwitch: 'Switch',
check: 'Checkbox',
button: 'Button',
group: 'Group',
externalGroup: 'Group (no input, external state)',
option: 'Option',
dropdown: 'Dropdown',
text: 'Text Input',
markdown: 'Markdown Editor',
appComponent: 'Custom Component',
}
const FACTORY_NAMES = new Set(Object.keys(FACTORY_INPUT_TYPES))
interface NodeInfo {
type: string
id?: string
label?: string
shown?: string
suggestedStatus?: string
messagePath?: string
fix?: string
priority?: string
hint?: string
guidance?: string
navigate?: string
children: NodeInfo[]
}
function unwrapParens(node: Node): Node {
let current = node
while (current.getKind() === SyntaxKind.ParenthesizedExpression) {
current = (current as unknown as { getExpression(): Node }).getExpression()
}
return current
}
function normalizeCode(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
function resolveRelativeMessagePath(name: string, statePath: string[]): string {
if (name.startsWith('/')) return `checklist/messages${name}`
const parts = [...statePath.slice(0, -1), ...name.split('/')]
const normalized = parts.reduce<string[]>((acc, p) => {
if (p === '..') acc.pop()
else if (p) acc.push(p)
return acc
}, [])
return `checklist/messages/${normalized.join('/')}`
}
function autoMessagePath(statePath: string[]): string {
return `checklist/messages/${statePath.join('/')}`
}
function literalText(arg: Node | undefined): string | undefined {
if (!arg) return undefined
if (arg.getKind() === SyntaxKind.StringLiteral || arg.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
return (arg as unknown as { getLiteralText(): string }).getLiteralText()
}
return undefined
}
function findDocsComment(node: Node): string | undefined {
let current: Node | undefined = node
while (current) {
const ranges = current.getLeadingCommentRanges()
for (const range of ranges) {
const text = range.getText()
const match = text.match(/@docs\s+([\s\S]*?)(?:\*\/)?\s*$/)
if (match) {
return match[1]
.replace(/^\/\*+\s*|\s*\*+\/$/g, '')
.replace(/^\/\/\s*/g, '')
.trim()
}
}
const parent = current.getParent()
if (!parent || current.getKind() === SyntaxKind.ExpressionStatement) break
current = parent
}
return undefined
}
function unwindChain(expr: CallExpression): { root: CallExpression | undefined; calls: CallExpression[] } {
const calls: CallExpression[] = []
let current: Node = expr
while (current.getKind() === SyntaxKind.CallExpression) {
const call = current as CallExpression
const callee = call.getExpression()
if (callee.getKind() === SyntaxKind.PropertyAccessExpression) {
calls.unshift(call)
current = (callee as unknown as { getExpression(): Node }).getExpression()
} else if (callee.getKind() === SyntaxKind.Identifier) {
return { root: call, calls }
} else {
break
}
}
return { root: undefined, calls }
}
const FUNCTION_LIKE_KINDS = new Set([
SyntaxKind.ArrowFunction,
SyntaxKind.FunctionExpression,
SyntaxKind.FunctionDeclaration,
SyntaxKind.MethodDeclaration,
])
function getOwnReturnStatements(fn: Node): ReturnStatement[] {
return fn.getDescendantsOfKind(SyntaxKind.ReturnStatement).filter((ret) => {
let current: Node | undefined = ret.getParent()
while (current && current !== fn) {
if (FUNCTION_LIKE_KINDS.has(current.getKind())) return false
current = current.getParent()
}
return current === fn
})
}
function resolveLocalHelperCall(call: CallExpression, path: string[]): NodeInfo | undefined {
const callee = call.getExpression()
if (callee.getKind() !== SyntaxKind.Identifier) return undefined
const name = callee.getText()
const sourceFile = call.getSourceFile()
const fn = sourceFile
.getDescendantsOfKind(SyntaxKind.FunctionDeclaration)
.find((f) => f.getName() === name)
if (!fn) return { type: 'UNRESOLVED', children: [] }
const returnStatements = getOwnReturnStatements(fn)
for (const ret of returnStatements) {
const returnExpr = ret.getExpression() ? unwrapParens(ret.getExpression()!) : undefined
if (returnExpr && returnExpr.getKind() === SyntaxKind.CallExpression) {
const resolved = resolveNode(returnExpr as CallExpression, path)
if (resolved) return resolved
}
}
return { type: 'UNRESOLVED', children: [] }
}
function resolveChildEntry(rawArg: Node, path: string[]): NodeInfo[] {
const arg = unwrapParens(rawArg)
if (arg.getKind() === SyntaxKind.CallExpression) {
const call = arg as CallExpression
const callee = call.getExpression()
if (callee.getKind() === SyntaxKind.Identifier && !FACTORY_NAMES.has(callee.getText())) {
const calleeName = callee.getText()
if (calleeName === 'computed' || calleeName === 'ref') {
const docs = findDocsComment(call)
return [
{
type: docs ? `Dynamic children: ${docs}` : 'UNRESOLVED',
children: [],
},
]
}
return [resolveLocalHelperCall(call, path)!]
}
const resolved = resolveNode(call, path)
return [resolved ?? { type: 'UNRESOLVED', children: [] }]
}
if (arg.getKind() === SyntaxKind.SpreadElement) {
const spreadArg = (arg as unknown as { getExpression(): Node }).getExpression()
return resolveChildEntry(spreadArg, path)
}
if (arg.getKind() === SyntaxKind.ArrayLiteralExpression) {
return arg
.getChildrenOfKind(SyntaxKind.SyntaxList)
.flatMap((list) => list.getChildren().flatMap((c) => resolveChildEntry(c, path)))
}
if (arg.getKind() === SyntaxKind.ArrowFunction || arg.getKind() === SyntaxKind.FunctionExpression) {
return []
}
if (arg.getKind() === SyntaxKind.StringLiteral) {
return []
}
return [{ type: 'UNRESOLVED', children: [] }]
}
function resolveNode(expr: CallExpression, path: string[]): NodeInfo | undefined {
const { root, calls } = unwindChain(expr)
if (!root) return undefined
const factoryName = root.getExpression().getText()
const type = FACTORY_INPUT_TYPES[factoryName]
if (!type) return undefined
const rootArgs = root.getArguments()
const info: NodeInfo = {
type,
id: literalText(rootArgs[0]),
label: literalText(rootArgs[1]),
children: [],
}
const nodePath = info.id ? [...path, info.id] : path
for (const call of calls) {
const callee = call.getExpression()
if (callee.getKind() !== SyntaxKind.PropertyAccessExpression) continue
const methodName = (callee as unknown as { getName(): string }).getName()
const args = call.getArguments()
switch (methodName) {
case 'shown':
info.shown = findDocsComment(call) ?? (args[0] ? normalizeCode(args[0].getText()) : undefined)
break
case 'suggestedStatus':
info.suggestedStatus = literalText(args[0])
break
case 'message': {
const arg0 = args[0]
const literal = literalText(arg0)
if (literal !== undefined) {
info.messagePath = resolveRelativeMessagePath(literal, nodePath)
} else if (
arg0 &&
(arg0.getKind() === SyntaxKind.ArrowFunction ||
arg0.getKind() === SyntaxKind.FunctionExpression)
) {
const paramCount = (
arg0 as unknown as { getParameters(): unknown[] }
).getParameters().length
info.messagePath =
paramCount >= 1 ? autoMessagePath(nodePath) : normalizeCode(arg0.getText())
} else if (arg0) {
info.messagePath = normalizeCode(arg0.getText())
} else {
info.messagePath = autoMessagePath(nodePath)
}
break
}
case 'fix':
info.fix = findDocsComment(call) ?? (args[0] ? normalizeCode(args[0].getText()) : 'UNRESOLVED')
break
case 'priority':
info.priority = args[0] ? normalizeCode(args[0].getText()) : undefined
break
case 'hint':
info.hint = literalText(args[0])
break
case 'guidance':
info.guidance = literalText(args[0])
break
case 'navigate':
info.navigate = literalText(args[0])
break
case 'children':
for (const arg of args) {
info.children.push(...resolveChildEntry(arg, nodePath))
}
break
default:
break
}
}
return info
}
function pruneForJson(node: NodeInfo): Record<string, unknown> {
const out: Record<string, unknown> = { type: node.type }
if (node.id) out.id = node.id
if (node.label) out.label = node.label
if (node.shown) out.shown = node.shown
if (node.fix) out.fix = node.fix
if (node.suggestedStatus) out.suggestedStatus = node.suggestedStatus
if (node.messagePath) out.messagePath = node.messagePath
if (node.priority) out.priority = node.priority
if (node.hint) out.hint = node.hint
if (node.guidance) out.guidance = node.guidance
if (node.navigate) out.navigate = node.navigate
if (node.children.length > 0) out.children = node.children.map(pruneForJson)
return out
}
function main() {
const project = new Project({
tsConfigFilePath: join(PACKAGE_ROOT, 'tsconfig.json'),
})
project.addSourceFilesAtPaths(STAGES_GLOB)
const jsonOutput: Record<string, NodeInfo | undefined> = {}
for (const sourceFile of project.getSourceFiles(STAGES_GLOB)) {
const stageFileName = sourceFile.getBaseNameWithoutExtension()
const defaultExport =
sourceFile.getFunction((f) => f.isDefaultExport()) ??
sourceFile.getExportAssignments()[0]?.getExpression()
let returnExpr: Node | undefined
if (defaultExport && 'getDescendantsOfKind' in defaultExport) {
const returnStatements = getOwnReturnStatements(defaultExport as unknown as Node)
const lastReturnExpr = returnStatements[returnStatements.length - 1]?.getExpression()
returnExpr = lastReturnExpr ? unwrapParens(lastReturnExpr) : undefined
}
if (!returnExpr || returnExpr.getKind() !== SyntaxKind.CallExpression) {
console.warn(`[skip] ${stageFileName}: could not find a default-export stage() call`)
continue
}
const stageNode = resolveNode(returnExpr as CallExpression, [])
if (!stageNode) {
console.warn(`[skip] ${stageFileName}: default export didn't resolve to a stage() chain`)
continue
}
jsonOutput[stageFileName] = stageNode
}
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true })
const prunedJsonOutput = Object.fromEntries(
Object.entries(jsonOutput).map(([key, node]) => [key, node ? pruneForJson(node) : node]),
)
const jsonPath = join(OUT_DIR, 'checklist-structure.json')
writeFileSync(jsonPath, JSON.stringify(prunedJsonOutput, null, 2))
console.log(`Wrote ${Object.keys(jsonOutput).length} stages to:`)
console.log(` ${jsonPath}`)
}
main()
+26 -1
View File
@@ -570,6 +570,9 @@ importers:
'@modrinth/ui':
specifier: workspace:*
version: link:../ui
ts-morph:
specifier: ^24.0.0
version: 24.0.0
typescript:
specifier: ^5.4.5
version: 5.9.3
@@ -4558,6 +4561,9 @@ packages:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
'@ts-morph/common@0.25.0':
resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -5710,6 +5716,9 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -9506,6 +9515,9 @@ packages:
ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
ts-morph@24.0.0:
resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==}
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -14095,6 +14107,12 @@ snapshots:
'@trysound/sax@0.2.0': {}
'@ts-morph/common@0.25.0':
dependencies:
minimatch: 9.0.5
path-browserify: 1.0.1
tinyglobby: 0.2.15
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.1':
@@ -15507,6 +15525,8 @@ snapshots:
cluster-key-slot@1.1.2: {}
code-block-writer@13.0.3: {}
collapse-white-space@2.1.0: {}
color-convert@2.0.1:
@@ -16654,7 +16674,7 @@ snapshots:
glob@13.0.1:
dependencies:
minimatch: 10.1.2
minimatch: 10.2.3
minipass: 7.1.2
path-scurry: 2.0.1
@@ -20248,6 +20268,11 @@ snapshots:
ts-map@1.0.3: {}
ts-morph@24.0.0:
dependencies:
'@ts-morph/common': 0.25.0
code-block-writer: 13.0.3
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3