mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
checklist docs generator
This commit is contained in:
@@ -5,6 +5,9 @@ dist
|
||||
tmp
|
||||
/out-tsc
|
||||
|
||||
# generated checklist structure dump (packages/moderation/scripts/checklist-docs.ts)
|
||||
.checklist-docs
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
|
||||
@@ -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()
|
||||
Generated
+26
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user