diff --git a/.gitignore b/.gitignore index 064fbf0449..f987875a74 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist tmp /out-tsc +# generated checklist structure dump (packages/moderation/scripts/checklist-docs.ts) +.checklist-docs + # dependencies node_modules diff --git a/packages/moderation/package.json b/packages/moderation/package.json index 5409c54d5e..b916355a8b 100644 --- a/packages/moderation/package.json +++ b/packages/moderation/package.json @@ -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" } } diff --git a/packages/moderation/scripts/checklist-docs.ts b/packages/moderation/scripts/checklist-docs.ts new file mode 100644 index 0000000000..d675e6ae72 --- /dev/null +++ b/packages/moderation/scripts/checklist-docs.ts @@ -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 = { + 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((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 { + const out: Record = { 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 = {} + + 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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c610afec87..7899483f7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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