From e0fc6b49a53a7c41684f6177a965fa75d7c894e3 Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Thu, 28 May 2026 11:41:44 -0400 Subject: [PATCH 01/26] initial commit for autodoc tool --- .changeset/red-students-tap.md | 5 + .gitignore | 3 +- package-lock.json | 1224 +++++++++++------ packages/autodoc/README.md | 2 + packages/autodoc/jest.config.js | 13 + packages/autodoc/package.json | 40 + packages/autodoc/src/cli.ts | 110 ++ packages/autodoc/src/parsers/extension.ts | 14 + packages/autodoc/src/parsers/plugin.ts | 361 +++++ packages/autodoc/src/parsers/timeline.ts | 10 + packages/autodoc/src/renderers/plugin.ts | 149 ++ packages/autodoc/src/types/info.ts | 38 + packages/autodoc/src/utils.ts | 69 + .../autodoc/tests/fixtures/extension/basic.ts | 0 .../autodoc/tests/fixtures/plugin/basic.ts | 64 + .../examples/complex-inferred-example.html | 24 + .../examples/complex-sentinel-example.html | 40 + .../plugin/examples/ignored-example.html | 15 + .../examples/simple-inferred-example.html | 18 + .../examples/simple-sentinel-example.html | 20 + .../autodoc/tests/fixtures/timeline/basic.ts | 0 packages/autodoc/tests/parsers/plugin.test.ts | 92 ++ .../autodoc/tests/renderers/plugin.test.ts | 3 + packages/autodoc/tests/utils.ts | 7 + packages/autodoc/tsconfig.json | 19 + 25 files changed, 1913 insertions(+), 427 deletions(-) create mode 100644 .changeset/red-students-tap.md create mode 100644 packages/autodoc/README.md create mode 100644 packages/autodoc/jest.config.js create mode 100644 packages/autodoc/package.json create mode 100644 packages/autodoc/src/cli.ts create mode 100644 packages/autodoc/src/parsers/extension.ts create mode 100644 packages/autodoc/src/parsers/plugin.ts create mode 100644 packages/autodoc/src/parsers/timeline.ts create mode 100644 packages/autodoc/src/renderers/plugin.ts create mode 100644 packages/autodoc/src/types/info.ts create mode 100644 packages/autodoc/src/utils.ts create mode 100644 packages/autodoc/tests/fixtures/extension/basic.ts create mode 100644 packages/autodoc/tests/fixtures/plugin/basic.ts create mode 100644 packages/autodoc/tests/fixtures/plugin/examples/complex-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/plugin/examples/complex-sentinel-example.html create mode 100644 packages/autodoc/tests/fixtures/plugin/examples/ignored-example.html create mode 100644 packages/autodoc/tests/fixtures/plugin/examples/simple-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/plugin/examples/simple-sentinel-example.html create mode 100644 packages/autodoc/tests/fixtures/timeline/basic.ts create mode 100644 packages/autodoc/tests/parsers/plugin.test.ts create mode 100644 packages/autodoc/tests/renderers/plugin.test.ts create mode 100644 packages/autodoc/tests/utils.ts create mode 100644 packages/autodoc/tsconfig.json diff --git a/.changeset/red-students-tap.md b/.changeset/red-students-tap.md new file mode 100644 index 0000000..76fb163 --- /dev/null +++ b/.changeset/red-students-tap.md @@ -0,0 +1,5 @@ +--- +"@jspsych/autodoc": minor +--- + +Initial implementation of @jspsych/autodoc, a CLI tool for generating documentation from jsPsych package files. diff --git a/.gitignore b/.gitignore index a9161a9..2b197b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .vscode -.DS_Store \ No newline at end of file +.DS_Store +dist \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d37bc7d..e28172d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1186,6 +1186,91 @@ "node": ">=14.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-x64": { "version": "0.23.1", "cpu": [ @@ -1201,6 +1286,312 @@ "node": ">=18" } }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@gulpjs/messages": { "version": "1.1.0", "license": "MIT", @@ -2031,6 +2422,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jspsych/autodoc": { + "resolved": "packages/autodoc", + "link": true + }, "node_modules/@jspsych/config": { "version": "3.2.2", "dev": true, @@ -2150,58 +2545,11 @@ "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" } }, "node_modules/@nodelib/fs.scandir": { @@ -2380,6 +2728,48 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", + "integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz", + "integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz", + "integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.21.2", "cpu": [ @@ -2392,6 +2782,174 @@ "darwin" ] }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz", + "integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz", + "integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz", + "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz", + "integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz", + "integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz", + "integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz", + "integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz", + "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz", + "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz", + "integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz", + "integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz", + "integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "dev": true, @@ -2659,13 +3217,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/abbrev": { - "version": "1.1.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/acorn": { "version": "8.14.0", "dev": true, @@ -2821,42 +3372,6 @@ "node": ">= 6.0.0" } }, - "node_modules/aproba": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/are-we-there-yet/node_modules/readable-stream": { - "version": "3.6.2", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/argparse": { "version": "1.0.10", "dev": true, @@ -3254,6 +3769,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "dev": true, @@ -3373,22 +3901,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas": { - "version": "2.11.2", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "nan": "^2.17.0", - "simple-get": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "5.4.1", "dev": true, @@ -3434,16 +3946,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/ci-info": { "version": "3.9.0", "dev": true, @@ -3627,16 +4129,6 @@ "version": "1.1.4", "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/colorette": { "version": "1.4.0", "dev": true, @@ -3679,13 +4171,6 @@ "dev": true, "license": "MIT" }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "license": "MIT" @@ -3851,19 +4336,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decompress-response": { - "version": "4.2.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mimic-response": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dedent": { "version": "1.5.3", "dev": true, @@ -3949,13 +4421,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/detect-file": { "version": "1.0.0", "license": "MIT", @@ -3971,16 +4436,6 @@ "node": ">=8" } }, - "node_modules/detect-libc": { - "version": "2.0.3", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "dev": true, @@ -4606,42 +5061,9 @@ "universalify": "^0.1.0" }, "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">=6 <7 || >=8" } }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/fs-mkdirp-stream": { "version": "2.0.1", "license": "MIT", @@ -4676,34 +5098,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -5089,6 +5483,28 @@ "node": ">= 10.13.0" } }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "license": "MIT", @@ -5096,13 +5512,6 @@ "node": ">=8" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -7516,6 +7925,13 @@ "node": ">=8" } }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.startcase": { "version": "4.4.0", "dev": true, @@ -7604,6 +8020,13 @@ "semver": "bin/semver" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "dev": true, @@ -7679,19 +8102,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.2", "dev": true, @@ -7719,53 +8129,6 @@ "node": ">=8" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/module-alias": { "version": "2.2.3", "dev": true, @@ -7820,13 +8183,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nan": { - "version": "2.22.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -7896,22 +8252,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "5.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", @@ -7940,19 +8280,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/nwsapi": { "version": "2.2.16", "dev": true, @@ -8658,22 +8985,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.21.2", "dev": true, @@ -8847,7 +9158,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -8872,13 +9185,6 @@ "node": ">= 10.13.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -8919,39 +9225,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/simple-get": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "decompress-response": "^4.2.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-git": { "version": "3.27.0", "license": "MIT", @@ -9312,31 +9585,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/tar": { - "version": "6.2.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/teex": { "version": "1.0.1", "license": "MIT", @@ -9551,6 +9799,72 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tslib": { "version": "2.6.2", "dev": true, @@ -9586,6 +9900,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "license": "MIT", @@ -9951,15 +10279,12 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } + "license": "MIT" }, "node_modules/wrap-ansi": { "version": "6.2.0", @@ -10160,9 +10485,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/autodoc": { + "name": "@jspsych/autodoc", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3" + }, + "bin": { + "autodoc": "dist/cli.js" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.6.2", + "ts-jest": "^29.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/autodoc/node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/autodoc/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "packages/autodoc/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/new-extension": { "name": "@jspsych/new-extension", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.2.3", @@ -10194,7 +10566,7 @@ }, "packages/new-plugin": { "name": "@jspsych/new-plugin", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.2.3", @@ -10226,7 +10598,7 @@ }, "packages/new-timeline": { "name": "@jspsych/new-timeline", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.2.3", diff --git a/packages/autodoc/README.md b/packages/autodoc/README.md new file mode 100644 index 0000000..e45c54c --- /dev/null +++ b/packages/autodoc/README.md @@ -0,0 +1,2 @@ +# `@jspsych/autodoc` + diff --git a/packages/autodoc/jest.config.js b/packages/autodoc/jest.config.js new file mode 100644 index 0000000..5ca5aeb --- /dev/null +++ b/packages/autodoc/jest.config.js @@ -0,0 +1,13 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: './tsconfig.json' }], + }, +}; diff --git a/packages/autodoc/package.json b/packages/autodoc/package.json new file mode 100644 index 0000000..ca4c416 --- /dev/null +++ b/packages/autodoc/package.json @@ -0,0 +1,40 @@ +{ + "name": "@jspsych/autodoc", + "version": "0.0.1", + "description": "CLI tool to generate documentation for jsPsych plugins", + "type": "module", + "bin": "./dist/cli.js", + "files": [ + "dist", + "templates" + ], + "scripts": { + "build": "tsc", + "start": "node dist/cli.js", + "test": "node --experimental-vm-modules ../../node_modules/.bin/jest" + }, + "keywords": [ + "jspsych", + "psychology", + "documentation", + "cli" + ], + "author": "jade", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.6.2", + "ts-jest": "^29.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts new file mode 100644 index 0000000..ded6f6d --- /dev/null +++ b/packages/autodoc/src/cli.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +import { Command } from "commander"; + +import { updateDocSections } from "./utils.js"; +import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; +import { getPluginDocs } from "./renderers/plugin.js"; + +// auto get version from package.json +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "../package.json"), "utf8") +) as { version: string }; +const { version } = packageJson; + +interface CliOptions { + source?: string; + dest?: string; + example?: string; +} + +// TODO: simulation mode-- detect if simulation mode is supported via these plugins. +async function main(options: CliOptions): Promise { + let sourcePath: string; + if (options.source) { + sourcePath = options.source; + } else { + throw new Error("No source file provided. Please specify a source file with --source."); + } + + if (!options.dest) { + throw new Error("No destination file provided. Please specify a destination file with --dest."); + } + + const source = ts.createSourceFile( + sourcePath, + fs.readFileSync(sourcePath, 'utf-8'), + ts.ScriptTarget.Latest, + true + ); + + let pluginInfo; + if (options.example) { + pluginInfo = await getPluginInfoAndExamples(source, options.example); + } else { + pluginInfo = await getPluginInfo(source); + } + + try { + const pluginPackageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8") + ); + if (pluginPackageJson.version) { + pluginInfo.version = pluginPackageJson.version; + } else { + console.warn("Warning: No version field found in package.json."); + pluginInfo.version = "unknown version"; + } + } catch (err) { + console.warn("Warning: Could not read package.json to determine version. Ensure you are running the CLI in the directory that contains the package.json."); + pluginInfo.version = "unknown version"; + } + + const docs = await getPluginDocs(pluginInfo); + + const rawContent = Object.values(docs).join("\n\n"); + if (!fs.existsSync(options.dest)) { + fs.writeFileSync(options.dest, rawContent, "utf8"); + } else { + const existingContent = fs.readFileSync(options.dest, "utf8"); + if (existingContent.trim() === "") { + fs.writeFileSync(options.dest, rawContent, "utf8"); + } else { + const updatedContent = updateDocSections(existingContent, docs); + fs.writeFileSync(options.dest, updatedContent, "utf8"); + } + } +} + +const program = new Command(); + +program + .name("autodoc") + .description("CLI tool to generate documentation for jsPsych plugins") + .version(version) + .option("--source ", "Source of the package") + .option("--dest ", "Destination directory for the generated documentation") + .option("--repo ", "Repository that contains the source/destination files (optional)") + .option("--example ", "Example folder containing usages of the plugin (optional)") + .option("-v, --verbose", "Enable verbose logging (optional)") + .option("-f, --force", "Force overwrite of existing documentation (optional, use with caution or with --copy)") + .option("--copy ", "Copy original documentation to a specified location (optional)") + .addHelpText( + "after", + ` +Examples: + $ autodoc --source /src/index.ts --dest /docs/index.md + $ autodoc --source /src/index.ts --dest /docs/index.md --example /examples/` + ); + +program.parse(); +const options = program.opts(); +await main(options); diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts new file mode 100644 index 0000000..754b45b --- /dev/null +++ b/packages/autodoc/src/parsers/extension.ts @@ -0,0 +1,14 @@ + + + + + + +export async function getExtensionInfo(info: any): Promise { + // Placeholder implementation, replace with actual logic to retrieve documentation + return [ + `Documentation for extension: ${info.name}`, + `Description: ${info.description}`, + `Version: ${info.version}`, + ]; +} \ No newline at end of file diff --git a/packages/autodoc/src/parsers/plugin.ts b/packages/autodoc/src/parsers/plugin.ts new file mode 100644 index 0000000..6d423b5 --- /dev/null +++ b/packages/autodoc/src/parsers/plugin.ts @@ -0,0 +1,361 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { PluginInfo, ParameterInfo, ExampleInfo } from "../types/info.js"; + +/** Grabs JSDoc comments from a node. */ +function extractJsDocComment(node: ts.Node, source: ts.SourceFile): string | undefined { + const jsDoc = ts.getJSDocCommentsAndTags(node); + const rawComment = jsDoc[0] && ts.isJSDoc(jsDoc[0]) ? jsDoc[0].comment : undefined; + return (typeof rawComment === "string" + ? rawComment + : rawComment?.map((n) => n.getText(source)).join("") + )?.replace(/\s*\n\s*/g, " ").trim(); +} + +/** Parses one parameter from an parameter node. */ +function parseParamGroup(node: ts.ObjectLiteralExpression, source: ts.SourceFile): Record { + const result: Record = {}; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isObjectLiteralExpression(prop.initializer)) continue; + const name = prop.name.getText(source); + const info = extractParameter(prop.initializer, source); + const comment = extractJsDocComment(prop, source); + if (comment) info.description = comment; + result[name] = info; + } + return result; +} + +/** Gathers parameter information (type, default, etc.) from a node. */ +function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFile): ParameterInfo { + const result: Partial = {}; + + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = prop.name.getText(source); + + switch (key) { + case "type": { + if (ts.isPropertyAccessExpression(prop.initializer)) { + result.type = prop.initializer.getText(source); + } + break; + } + case "default": { + result.default = prop.initializer.getText(source); + break; + } + case "array": { + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + result.array = true; + } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { + result.array = false; + } + break; + } + case "nested": { + if (ts.isObjectLiteralExpression(prop.initializer)) { + result.nested = parseParamGroup(prop.initializer, source); + } + break; + } + } + } + + return result as ParameterInfo; +} + +/** + * Extracts plugin information from a TypeScript AST. Source must already be + * transformed via the TypeScript compiler. Version and examples are not included + * in this function, but are gathered from the main CLI and from + * getPluginInfoAndExamples, respectively. + * + * @param source TypeScript AST of the source file + * @returns a PluginInfo object containing name, description, parameters, and data. + */ +export async function getPluginInfo(source: ts.SourceFile): Promise { + let result: PluginInfo = { + name: "", + description: "", + version: "", + parameters: {}, + data: {}, + examples: {}, + }; + + let classNode: ts.ClassDeclaration | undefined; + function visitClass(node: ts.Node) { + if (ts.isClassDeclaration(node)) { // check if class is a jsPsychPlugin + const implementsPlugin = node.heritageClauses?.some(h => + h.token === ts.SyntaxKind.ImplementsKeyword && + h.types.some(t => t.getText(source).includes("JsPsychPlugin")) + ); + if (implementsPlugin) classNode = node; + else + throw new Error("Plugin does not implement jsPsychPlugin interface. (how did we get here??)"); + } + ts.forEachChild(node, visitClass); + } + visitClass(source); + + if (classNode) { + const comment = extractJsDocComment(classNode, source); + if (comment) + result.description = comment; + else + console.warn("No JSDoc comment found for plugin class"); + } else { + throw new Error("Class does not properly implement jsPsychPlugin (how did we get here?)"); + } + + let infoNode: ts.ObjectLiteralExpression | undefined; + function visit(node: ts.Node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === "info") { + let init = node.initializer; + // unwrap b/c of const assertion + if (init && ts.isTypeAssertionExpression(init)) + init = init.expression; + if (init && ts.isObjectLiteralExpression(init)) + infoNode = init; + } + ts.forEachChild(node, visit); + } + + visit(source); + + if (infoNode === undefined) { + throw new Error("Could not find info object in plugin file"); + } + + const nameProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "name" + ) as ts.PropertyAssignment | undefined; + if (nameProp && ts.isStringLiteral(nameProp.initializer)) { + result.name = nameProp.initializer.text; + } + + const parametersProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "parameters" + ) as ts.PropertyAssignment | undefined; + + if (parametersProp && ts.isObjectLiteralExpression(parametersProp.initializer)) { + result.parameters = parseParamGroup(parametersProp.initializer, source); + } + + const dataProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "data" + ) as ts.PropertyAssignment | undefined; + + if (dataProp && ts.isObjectLiteralExpression(dataProp.initializer)) { + result.data = parseParamGroup(dataProp.initializer, source); + } + + return result; +} + + +/** + * Fallback code block extractor for HTML example files without sentinels. Requires exactly + * one inline script block (errors if zero or multiple are found). Parses the script with the + * TypeScript compiler, then collects all potential trial variables (has trial in the name, camel or snake case). + * For each trial, its direct local dependencies (one level of indirection) are also included by walking identifier references in + * the initializer and matching them against other locally declared variables. + */ +function inferCodeBlock(sourceContent: string, sourcePath: string): string { + const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; + const blocks: string[] = []; + let match: RegExpExecArray | null; + while ((match = scriptRegex.exec(sourceContent)) !== null) + blocks.push(match[1]); + + if (blocks.length === 0) + throw new Error(`${sourcePath}: no inline script blocks found`); + if (blocks.length > 1) + throw new Error(`${sourcePath}: multiple inline script blocks found — use jspsych-autodoc:start/end sentinels instead`); + + const scriptContent = blocks[0]; + const sourceFile = ts.createSourceFile("example.js", scriptContent, ts.ScriptTarget.Latest, true); + + const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; + const trialNodes: ts.VariableDeclaration[] = []; + + function visitTrials(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && trialPattern.test(node.name.text)) { + if (ts.isObjectLiteralExpression(node.initializer!)) + trialNodes.push(node); + else + // TODO: explore support for non-object-literal trial initializers (e.g. buildTrial()) + throw new Error(`${sourcePath}: trial variable "${node.name.text}" has a non-object-literal initializer — use jspsych-autodoc:start/end sentinels instead`); + } + ts.forEachChild(node, visitTrials); + } + visitTrials(sourceFile); + + if (trialNodes.length === 0) + throw new Error(`${sourcePath}: no trial variables found — use jspsych-autodoc:start/end sentinels instead`); + + // build map of all local variable declarations, excluding trial nodes themselves + const localDecls = new Map(); + function visitDecls(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const stmt = node.parent.parent; + if (ts.isVariableStatement(stmt)) + localDecls.set(node.name.text, stmt); + } + ts.forEachChild(node, visitDecls); + } + visitDecls(sourceFile); + for (const trial of trialNodes) + localDecls.delete((trial.name as ts.Identifier).text); + + // Collect identifier references from a node, skipping property assignment keys + function collectIdentifiers(node: ts.Node, result: Set) { + if (ts.isPropertyAssignment(node)) { + collectIdentifiers(node.initializer, result); + return; + } + if (ts.isIdentifier(node)) { + result.add(node.text); + return; + } + ts.forEachChild(node, child => collectIdentifiers(child, result)); + } + + // gather trial statements and their one-level dependencies, keyed by name to deduplicate + const outputStatements = new Map(); + for (const trial of trialNodes) { + const trialStmt = trial.parent.parent; + if (ts.isVariableStatement(trialStmt)) + outputStatements.set((trial.name as ts.Identifier).text, trialStmt); + + const refs = new Set(); + collectIdentifiers(trial.initializer!, refs); + for (const ref of refs) + if (localDecls.has(ref)) + outputStatements.set(ref, localDecls.get(ref)!); + } + + return Array.from(outputStatements.values()) + .sort((a, b) => a.pos - b.pos) + .map(node => node.getText(sourceFile).trim()) + .join("\n\n"); +} + +/** + * Gets the example code block text from a given HTML file. Looks for sentinels first and + * orders sub-blocks via file position. Otherwise, infers based on trial variable declarations + * and their dependencies. + */ +function getCodeBlock(sourceContent: string, sourcePath: string): string { + const START = "// jspsych-autodoc:start"; + const END = "// jspsych-autodoc:end"; + + type Marker = { type: "start" | "end"; pos: number }; + const markers: Marker[] = []; + + let i = 0; + while (i < sourceContent.length) { + const s = sourceContent.indexOf(START, i); + const e = sourceContent.indexOf(END, i); + if (s === -1 && e === -1) break; + if (s !== -1 && (e === -1 || s < e)) { + markers.push({ type: "start", pos: s }); + i = s + START.length; + } else { + markers.push({ type: "end", pos: e }); + i = e + END.length; + } + } + + if (markers.length === 0) + return inferCodeBlock(sourceContent, sourcePath); + + for (let j = 0; j < markers.length; j++) { + const expected = j % 2 === 0 ? "start" : "end"; + if (markers[j].type !== expected) + throw new Error(`${sourcePath}: mismatched jspsych-autodoc sentinels: unexpected ${markers[j].type} at marker ${j + 1}`); + } + if (markers.length % 2 !== 0) + throw new Error(`${sourcePath}: mismatched jspsych-autodoc sentinels: last start has no matching end`); + + const blocks: string[] = []; + for (let j = 0; j < markers.length; j += 2) { + const newlineAfterStart = sourceContent.indexOf("\n", markers[j].pos); + const blockStart = newlineAfterStart === -1 ? markers[j].pos + START.length : newlineAfterStart + 1; + const blockEnd = markers[j + 1].pos; + blocks.push(sourceContent.slice(blockStart, blockEnd).trimEnd()); + } + + return blocks.join("\n\n"); +} + +/** Fetch example information from a given HTML filepath. `undefined` if the file is ignored + * via sentinel . */ +function getExampleInfo(sourcePath: string): Record | undefined { + const content = fs.readFileSync(sourcePath, "utf-8"); + + if (//.test(content)) + return undefined; + + let title: string; + + const sentinelMatch = content.match(//); + if (sentinelMatch) { + title = sentinelMatch[1].trim(); + } else { + const titleTagMatch = content.match(/([\s\S]*?)<\/title>/i); + if (!titleTagMatch) + throw new Error(`No title found in example file: ${sourcePath}`); + title = titleTagMatch[1].trim(); + } + + return { + [title]: { path: sourcePath, code: getCodeBlock(content, sourcePath) } + }; +} + +/** + * Extracts plugin information from a TypeScript AST. Source must already be + * transformed via the TypeScript compiler. Also gathers example information + * from the provided example path. + * + * @param source TypeScript AST of the source file + * @param examplePath Path to an example file or directory + * @returns a PluginInfo object containing name, description, version, parameters, data, and examples. + */ +export async function getPluginInfoAndExamples(source: ts.SourceFile, examplePath: string): Promise<PluginInfo> { + const info = await getPluginInfo(source); + + if (!fs.existsSync(examplePath)) { + throw new Error(`Example path does not exist: ${examplePath}`); + } + + const stat = fs.statSync(examplePath); + const htmlFiles: string[] = []; + + if (stat.isDirectory()) { + htmlFiles.push( + ...fs.readdirSync(examplePath) + .filter(f => f.endsWith(".html")) + .map(f => path.join(examplePath, f)) + ); + } else if (stat.isFile()) { + if (!examplePath.endsWith(".html")) { + throw new Error(`Example file must be an HTML file: ${examplePath}`); + } + htmlFiles.push(examplePath); + } else { + throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); + } + + for (const file of htmlFiles) { + const exampleInfo = getExampleInfo(file); + if (exampleInfo) + Object.assign(info.examples, exampleInfo); + } + + return info; +} \ No newline at end of file diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts new file mode 100644 index 0000000..43ea6c3 --- /dev/null +++ b/packages/autodoc/src/parsers/timeline.ts @@ -0,0 +1,10 @@ + + +export async function getTimelineInfo(info: any): Promise<string[]> { + // Placeholder implementation, replace with actual logic to retrieve documentation + return [ + `Documentation for timeline: ${info.name}`, + `Description: ${info.description}`, + `Version: ${info.version}`, + ]; +} \ No newline at end of file diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts new file mode 100644 index 0000000..2b6bc1d --- /dev/null +++ b/packages/autodoc/src/renderers/plugin.ts @@ -0,0 +1,149 @@ +import { ParameterInfo, PluginInfo, SectionTemplate } from "../types/info.js"; + +const stringifyTypeMap: Record<string, string> = { + "ParameterType.STRING": "string", + "ParameterType.INT": "integer", + "ParameterType.FLOAT": "float", + "ParameterType.BOOL": "boolean", + "ParameterType.FUNCTION": "function", + "ParameterType.KEY": "key", + "ParameterType.KEYS": "keys", + "ParameterType.SELECT": "selection", //TODO: infer type from options + "ParameterType.HTML_STRING": "HTML string", + "ParameterType.IMAGE": "image file", + "ParameterType.AUDIO": "audio file", + "ParameterType.VIDEO": "video file", + "ParameterType.OBJECT": "object", + "ParameterType.COMPLEX": "object", +} + +const getTypeName = (type: string, array?: boolean): string => { + const baseType = stringifyTypeMap[type] || type; + return array ? `array of ${baseType}` : baseType; +} + +const topParameterChart = +`| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- |`; + +const renderNestedParameterDescription = (nested: Record<string, ParameterInfo>): string => { + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested parameter "${name}" is missing a description.`); + param.description = "No description provided."; + } + const desc = param.nested + ? `${param.description} ${renderNestedParameterDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +} + +const renderParameterRow = (name: string, parameter: ParameterInfo): string => { + if (!parameter.description) { + console.warn(`Warning: Parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const defaultValue = !isNaN(parseFloat(parameter.default)) ? parameter.default : `\`${parameter.default}\``; + const description = parameter.nested + ? `${parameter.description} ${renderNestedParameterDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${defaultValue} | ${description} |`; +} + +const renderNestedDataDescription = (nested: Record<string, ParameterInfo>): string => { + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested data parameter "${name}" is missing a description.`); + param.description = "No description provided."; + } + const desc = param.nested + ? `${param.description} ${renderNestedDataDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +} + +const renderDataRow = (name: string, parameter: ParameterInfo): string => { + if (!parameter.description) { + console.warn(`Warning: Data parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const value = parameter.nested + ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${value} |`; +} + +const topDataChart = +`| Name | Type | Value | +| ---- | ---- | ----- |`; + +const mainTemplate: SectionTemplate<PluginInfo>[] = [ + { + heading: "introduction", + render: info => { + return ` +# ${info.name} + +${info.description} + +Current version: ${info.version}`.trim(); + } + }, + { + heading: "parameters", + render: info => { + const rows = Object.entries(info.parameters).map(([name, param]) => renderParameterRow(name, param)).join("\n"); + return ` +## Parameters + +In addition to the [parameters available in all plugins](https://www.jspsych.org/latest/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of \`undefined\` must be specified. Other parameters can be left unspecified if the default value is acceptable. + +${topParameterChart} +${rows} +`.trim(); + } + }, + { + heading: "data", + render: info => { + const rows = Object.entries(info.data).map(([name, param]) => renderDataRow(name, param)).join("\n"); + return ` +## Data + +In addition to the [default data collected by all plugins](https://www.jspsych.org/latest/overview/plugins#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +${topDataChart} +${rows} +`.trim(); + } + }, + { + heading: "examples", + render: info => { + const sections = Object.entries(info.examples).map(([title, example]) => +`### ${title} (${example.path}) + +\`\`\`js +${example.code} +\`\`\`` + ).join("\n\n"); + return ` +## Examples + +${sections} +`.trim(); + } + } +] + +export async function getPluginDocs(info: PluginInfo): Promise<Record<string, string>> { + return Object.fromEntries(mainTemplate.map(section => { + const content = section.render(info); + const wrapped = `<!-- jspsych-autodocs:${section.heading}:start -->\n${content}\n<!-- jspsych-autodocs:${section.heading}:end -->`; + return [section.heading, wrapped]; + })); +} \ No newline at end of file diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts new file mode 100644 index 0000000..a76d32b --- /dev/null +++ b/packages/autodoc/src/types/info.ts @@ -0,0 +1,38 @@ +export interface PluginInfo { + name: string; + description: string; + version: string; + parameters: Record<string, ParameterInfo>; + data: Record<string, ParameterInfo>; + examples: Record<string, ExampleInfo>; +} + +export interface ExtensionInfo { + name: string; + description: string; + version: string; + initializeParameters: Record<string, ParameterInfo>; + onStartParameters: Record<string, ParameterInfo>; + onLoadParameters: Record<string, ParameterInfo>; + onFinishParameters: Record<string, ParameterInfo>; + data: Record<string, ParameterInfo>; + examples: Record<string, ExampleInfo>; +} + +export interface ParameterInfo { + type: string; + default: string; + array?: boolean; + description?: string; + nested?: Record<string, ParameterInfo>; +} + +export interface ExampleInfo { + path: string; + code: string; +} + +export interface SectionTemplate<T> { + heading: string; + render: (info: T) => string; +} diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts new file mode 100644 index 0000000..ee35570 --- /dev/null +++ b/packages/autodoc/src/utils.ts @@ -0,0 +1,69 @@ +/** + * Updates sections of a file delimited by sentinel tags with new content from the docs object. + * The docs object should have keys corresponding to section headings and thus sentinel tags in + * the file. If any sentinel tags are missing in the original file, an error will immediately be + * thrown. + * + * @param fileContent the content of the file to be updated + * @param docs the documentation content to update the file with + * @returns the updated file content + */ +export function updateDocSections(fileContent: string, docs: Record<string, string>): string { + const headings = Object.keys(docs); + + let anyFound = false; + const statuses: Record<string, { startFound: boolean; endFound: boolean }> = {}; + + for (const heading of headings) { + const startFound = fileContent.includes(`<!-- jspsych-autodocs:${heading}:start -->`); + const endFound = fileContent.includes(`<!-- jspsych-autodocs:${heading}:end -->`); + statuses[heading] = { startFound, endFound }; + if (startFound || endFound) anyFound = true; + } + + if (!anyFound) { + throw new Error( + "No sentinel tags found, is this a valid jsPsych autodoc target? If not, create a new file with the CLI to observe the structure." + ); + } + + const errors: string[] = []; + for (const heading of headings) { + const { startFound, endFound } = statuses[heading]; + const startTag = `<!-- jspsych-autodocs:${heading}:start -->`; + const endTag = `<!-- jspsych-autodocs:${heading}:end -->`; + + if (!startFound && !endFound) { + errors.push( + `${heading} sentinel start and end tag was not found.\n` + + `Insert ${startTag} before the heading to complete the tag\n` + + `Insert ${endTag} after the chart to complete the tag` + ); + } else if (!startFound) { + errors.push( + `${heading} sentinel start tag was not found.\n` + + `Insert ${startTag} before the heading to complete the tag` + ); + } else if (!endFound) { + errors.push( + `${heading} sentinel end tag was not found.\n` + + `Insert ${endTag} after the chart to complete the tag` + ); + } + } + + if (errors.length > 0) { + throw new Error(errors.join("\n\n")); + } + + let result = fileContent; + for (const heading of headings) { + const startTag = `<!-- jspsych-autodocs:${heading}:start -->`; + const endTag = `<!-- jspsych-autodocs:${heading}:end -->`; + const start = result.indexOf(startTag); + const end = result.indexOf(endTag) + endTag.length; + result = result.slice(0, start) + docs[heading] + result.slice(end); + } + + return result; +} diff --git a/packages/autodoc/tests/fixtures/extension/basic.ts b/packages/autodoc/tests/fixtures/extension/basic.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/autodoc/tests/fixtures/plugin/basic.ts b/packages/autodoc/tests/fixtures/plugin/basic.ts new file mode 100644 index 0000000..8fffa0c --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/basic.ts @@ -0,0 +1,64 @@ +// import { JsPsych ... } + +// import { version } ... + +import { ParameterType, JsPsychPlugin } from "../../utils.js" + +/** A test jsPsych plugin. */ +class TestPlugin implements JsPsychPlugin<typeof info.parameters> { + trial(_display_element: HTMLElement, _trial: typeof info.parameters): void {} +} + +const info = <const>{ + name: "test-plugin", + version: "1.0.0", + parameters: { + /** Single-line description. */ + single: { + type: ParameterType.STRING, + default: undefined, + }, + /** + * Multi-line description. + * It has two lines for a parameter. + */ + double_double: { + type: ParameterType.INT, + default: 42, + }, + /** Imagine if we had an array. */ + list_of_stimuli: { + type: ParameterType.IMAGE, + default: [], + array: true, + }, + /** Now let's have a grid. */ + grid: { + type: ParameterType.COMPLEX, + default: null, + nested: { + /** With an x-coordinate. */ + x_coord: { + type: ParameterType.FLOAT, + }, + /** And a y-coordinate. */ + y_coord: { + type: ParameterType.FLOAT, + } + } + } + }, + data: { + /** Data parameter description. */ + data_param: { + type: ParameterType.FLOAT, + }, + /** + * Multi-line data parameter description. + * It has two lines for data. + */ + double_data: { + type: ParameterType.BOOL, + } + } +} \ No newline at end of file diff --git a/packages/autodoc/tests/fixtures/plugin/examples/complex-inferred-example.html b/packages/autodoc/tests/fixtures/plugin/examples/complex-inferred-example.html new file mode 100644 index 0000000..b174361 --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/examples/complex-inferred-example.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> +<head> + <title>complex inferred example + + + + + diff --git a/packages/autodoc/tests/fixtures/plugin/examples/complex-sentinel-example.html b/packages/autodoc/tests/fixtures/plugin/examples/complex-sentinel-example.html new file mode 100644 index 0000000..72ce9d9 --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/examples/complex-sentinel-example.html @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/plugin/examples/ignored-example.html b/packages/autodoc/tests/fixtures/plugin/examples/ignored-example.html new file mode 100644 index 0000000..efa4758 --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/examples/ignored-example.html @@ -0,0 +1,15 @@ + + + + + ignored example + + + + + diff --git a/packages/autodoc/tests/fixtures/plugin/examples/simple-inferred-example.html b/packages/autodoc/tests/fixtures/plugin/examples/simple-inferred-example.html new file mode 100644 index 0000000..9550bbe --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/examples/simple-inferred-example.html @@ -0,0 +1,18 @@ + + + + simple inferred example + + + + + diff --git a/packages/autodoc/tests/fixtures/plugin/examples/simple-sentinel-example.html b/packages/autodoc/tests/fixtures/plugin/examples/simple-sentinel-example.html new file mode 100644 index 0000000..0250c51 --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/examples/simple-sentinel-example.html @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/timeline/basic.ts b/packages/autodoc/tests/fixtures/timeline/basic.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts new file mode 100644 index 0000000..a0e0656 --- /dev/null +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -0,0 +1,92 @@ +import { getPluginInfo, getPluginInfoAndExamples } from '../../src/parsers/plugin.js'; +import ts from 'typescript'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.resolve(__dirname, '../fixtures/plugin/basic.ts'); +const fixtureSource = ts.createSourceFile( + fixturePath, + fs.readFileSync(fixturePath, 'utf-8'), + ts.ScriptTarget.Latest, + true +); + +describe('getPluginInfo', () => { + it('extracts name', async () => { + const info = await getPluginInfo(fixtureSource); + expect(info.name).toBe('test-plugin'); + }); + + it('extracts class JSDoc as description', async () => { + const info = await getPluginInfo(fixtureSource); + expect(info.description).toBe('A test jsPsych plugin.'); + }); + + it('extracts parameters with types and descriptions', async () => { + const info = await getPluginInfo(fixtureSource); + expect(info.parameters.single.type).toBe('ParameterType.STRING'); + expect(info.parameters.single.description).toBe('Single-line description.'); + expect(info.parameters.double_double.type).toBe('ParameterType.INT'); + expect(info.parameters.double_double.description).toBe('Multi-line description. It has two lines for a parameter.'); + }); + + it('extracts array flag on parameters', async () => { + const info = await getPluginInfo(fixtureSource); + expect(info.parameters.list_of_stimuli.array).toBe(true); + }); + + it('extracts data parameters', async () => { + const info = await getPluginInfo(fixtureSource); + expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); + expect(info.data.data_param.description).toBe('Data parameter description.'); + expect(info.data.double_data.type).toBe('ParameterType.BOOL'); + expect(info.data.double_data.description).toBe('Multi-line data parameter description. It has two lines for data.'); + }); +}); + +describe('getPluginInfoAndExamples', () => { + const examplesDir = path.resolve(__dirname, '../fixtures/plugin/examples'); + + it('should extract examples from provided file', async () => { + const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); + const info = await getPluginInfoAndExamples(fixtureSource, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['simple sentinel example']).toBeDefined(); + expect(info.examples['simple sentinel example'].path).toBe(filePath); + expect(info.examples['simple sentinel example'].code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello"\n};' + ); + }); + + it('should extract examples from provided directory', async () => { + const info = await getPluginInfoAndExamples(fixtureSource, examplesDir); + expect(Object.keys(info.examples)).toHaveLength(4); + expect(info.examples['ignored example']).toBeUndefined(); + + const simpleSentinelExample = info.examples['simple sentinel example']; + expect(simpleSentinelExample.path).toBe(path.join(examplesDir, 'simple-sentinel-example.html')); + expect(simpleSentinelExample.code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello"\n};' + ); + + const complexSentinelExample = info.examples['complex sentinel example']; + expect(complexSentinelExample.path).toBe(path.join(examplesDir, 'complex-sentinel-example.html')); + expect(complexSentinelExample.code).toBe( + 'var fixationTrial = {\n type: jsPsychTestPlugin,\n stimulus: "+"\n};\n\nvar stimulusTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Hello"\n};\n\nvar feedbackTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Correct!"\n};' + ); + + const inferredExample = info.examples['simple inferred example']; + expect(inferredExample.path).toBe(path.join(examplesDir, 'simple-inferred-example.html')); + expect(inferredExample.code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "World"\n};' + ); + + const complexInferredExample = info.examples['complex inferred example']; + expect(complexInferredExample.path).toBe(path.join(examplesDir, 'complex-inferred-example.html')); + expect(complexInferredExample.code).toBe( + 'var stimulus = "Hello, world!";\n\nvar duration = 1000;\n\nvar choices = ["f", "j"];\n\nvar trial = {\n type: jsPsychTestPlugin,\n stimulus: stimulus,\n trial_duration: duration,\n choices: choices\n};' + ); + }); +}) \ No newline at end of file diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts new file mode 100644 index 0000000..55f6993 --- /dev/null +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -0,0 +1,3 @@ +describe('plugin renderer', () => { + test.todo('renders plugin info'); +}); diff --git a/packages/autodoc/tests/utils.ts b/packages/autodoc/tests/utils.ts new file mode 100644 index 0000000..1c02a8d --- /dev/null +++ b/packages/autodoc/tests/utils.ts @@ -0,0 +1,7 @@ +export enum ParameterType { + BOOL, STRING, INT, FLOAT, FUNCTION, KEY, KEYS, SELECT, HTML_STRING, IMAGE, AUDIO, VIDEO, OBJECT, COMPLEX +} + +export interface JsPsychPlugin { + trial(display_element: HTMLElement, trial: T): void; +} diff --git a/packages/autodoc/tsconfig.json b/packages/autodoc/tsconfig.json new file mode 100644 index 0000000..73d8748 --- /dev/null +++ b/packages/autodoc/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["jest", "node"], + "isolatedModules": true + }, + "include": ["src", "tests", "package.json"], + "exclude": ["node_modules", "dist"] +} From c12a80b977ccadcb107c789314de43da4064f15a Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:29:40 -0400 Subject: [PATCH 02/26] add smart detection of jsPsych package type, begin utility function refactors, lint + tests --- packages/autodoc/src/cli.ts | 55 +- packages/autodoc/src/parsers/extension.ts | 35 +- packages/autodoc/src/parsers/plugin.ts | 564 +++++++++--------- packages/autodoc/src/parsers/utils.ts | 12 + packages/autodoc/src/renderers/plugin.ts | 209 +++---- packages/autodoc/src/utils.ts | 87 ++- packages/autodoc/tests/cli.test.ts | 48 ++ .../autodoc/tests/fixtures/extension/basic.ts | 52 ++ .../tests/fixtures/utils/both-interfaces.ts | 5 + .../autodoc/tests/fixtures/utils/no-class.ts | 1 + packages/autodoc/tests/parsers/plugin.test.ts | 22 +- packages/autodoc/tests/utils.ts | 4 + 12 files changed, 653 insertions(+), 441 deletions(-) create mode 100644 packages/autodoc/src/parsers/utils.ts create mode 100644 packages/autodoc/tests/cli.test.ts create mode 100644 packages/autodoc/tests/fixtures/utils/both-interfaces.ts create mode 100644 packages/autodoc/tests/fixtures/utils/no-class.ts diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index ded6f6d..233f841 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -7,16 +7,17 @@ import ts from "typescript"; import { Command } from "commander"; -import { updateDocSections } from "./utils.js"; +import { extractVersionFromPackageJson, identifyPackageType, updateDocSections } from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; import { getPluginDocs } from "./renderers/plugin.js"; +import { PluginInfo } from "./types/info.js"; // auto get version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageJson = JSON.parse( - fs.readFileSync(path.join(__dirname, "../package.json"), "utf8") + fs.readFileSync(path.join(__dirname, "../package.json"), "utf8"), ) as { version: string }; const { version } = packageJson; @@ -40,35 +41,34 @@ async function main(options: CliOptions): Promise { } const source = ts.createSourceFile( - sourcePath, - fs.readFileSync(sourcePath, 'utf-8'), - ts.ScriptTarget.Latest, - true + sourcePath, + fs.readFileSync(sourcePath, "utf-8"), + ts.ScriptTarget.Latest, + true, ); - let pluginInfo; - if (options.example) { - pluginInfo = await getPluginInfoAndExamples(source, options.example); - } else { - pluginInfo = await getPluginInfo(source); - } + const { classNode, type } = identifyPackageType(source); + let docs: Record; + + if (type === "extension") { + throw new Error("Extension autodocs are not yet supported. Please use the autodoc CLI with a plugin source file."); + } else if (type === "plugin") { + console.log("Identified package type: plugin"); - try { - const pluginPackageJson = JSON.parse( - fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8") - ); - if (pluginPackageJson.version) { - pluginInfo.version = pluginPackageJson.version; + let pluginInfo: PluginInfo; + if (options.example) { + pluginInfo = await getPluginInfoAndExamples(source, classNode, options.example); } else { - console.warn("Warning: No version field found in package.json."); - pluginInfo.version = "unknown version"; + pluginInfo = await getPluginInfo(source, classNode); } - } catch (err) { - console.warn("Warning: Could not read package.json to determine version. Ensure you are running the CLI in the directory that contains the package.json."); - pluginInfo.version = "unknown version"; + + pluginInfo.version = extractVersionFromPackageJson(); + + docs = await getPluginDocs(pluginInfo); + } else { + throw new Error("Unrecognized package type."); } - const docs = await getPluginDocs(pluginInfo); const rawContent = Object.values(docs).join("\n\n"); if (!fs.existsSync(options.dest)) { @@ -95,14 +95,17 @@ program .option("--repo ", "Repository that contains the source/destination files (optional)") .option("--example ", "Example folder containing usages of the plugin (optional)") .option("-v, --verbose", "Enable verbose logging (optional)") - .option("-f, --force", "Force overwrite of existing documentation (optional, use with caution or with --copy)") + .option( + "-f, --force", + "Force overwrite of existing documentation (optional, use with caution or with --copy)", + ) .option("--copy ", "Copy original documentation to a specified location (optional)") .addHelpText( "after", ` Examples: $ autodoc --source /src/index.ts --dest /docs/index.md - $ autodoc --source /src/index.ts --dest /docs/index.md --example /examples/` + $ autodoc --source /src/index.ts --dest /docs/index.md --example /examples/`, ); program.parse(); diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 754b45b..263448b 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -1,14 +1,29 @@ +import ts from "typescript"; +import { ExtensionInfo } from "../types/info.js"; +export async function getExtensionInfo(source: ts.SourceFile): Promise { + // Placeholder implementation, replace with actual logic to retrieve documentation + let result: ExtensionInfo = { + name: "", + description: "", + version: "", + initializeParameters: {}, + onStartParameters: {}, + onLoadParameters: {}, + onFinishParameters: {}, + data: {}, + examples: {}, + }; + return result; +} - - - -export async function getExtensionInfo(info: any): Promise { +export async function getExtensionInfoAndExamples( + source: ts.SourceFile, + examplePath: string, +): Promise { // Placeholder implementation, replace with actual logic to retrieve documentation - return [ - `Documentation for extension: ${info.name}`, - `Description: ${info.description}`, - `Version: ${info.version}`, - ]; -} \ No newline at end of file + const info = await getExtensionInfo(source); + + return info; +} diff --git a/packages/autodoc/src/parsers/plugin.ts b/packages/autodoc/src/parsers/plugin.ts index 6d423b5..6e06a1d 100644 --- a/packages/autodoc/src/parsers/plugin.ts +++ b/packages/autodoc/src/parsers/plugin.ts @@ -2,161 +2,132 @@ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; import { PluginInfo, ParameterInfo, ExampleInfo } from "../types/info.js"; - -/** Grabs JSDoc comments from a node. */ -function extractJsDocComment(node: ts.Node, source: ts.SourceFile): string | undefined { - const jsDoc = ts.getJSDocCommentsAndTags(node); - const rawComment = jsDoc[0] && ts.isJSDoc(jsDoc[0]) ? jsDoc[0].comment : undefined; - return (typeof rawComment === "string" - ? rawComment - : rawComment?.map((n) => n.getText(source)).join("") - )?.replace(/\s*\n\s*/g, " ").trim(); -} +import { extractJsDocComment } from "./utils.js"; /** Parses one parameter from an parameter node. */ -function parseParamGroup(node: ts.ObjectLiteralExpression, source: ts.SourceFile): Record { - const result: Record = {}; - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - if (!ts.isObjectLiteralExpression(prop.initializer)) continue; - const name = prop.name.getText(source); - const info = extractParameter(prop.initializer, source); - const comment = extractJsDocComment(prop, source); - if (comment) info.description = comment; - result[name] = info; - } - return result; +function parseParamGroup( + node: ts.ObjectLiteralExpression, + source: ts.SourceFile, +): Record { + const result: Record = {}; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isObjectLiteralExpression(prop.initializer)) continue; + const name = prop.name.getText(source); + const info = extractParameter(prop.initializer, source); + const comment = extractJsDocComment(prop, source); + if (comment) info.description = comment; + result[name] = info; + } + return result; } /** Gathers parameter information (type, default, etc.) from a node. */ function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFile): ParameterInfo { - const result: Partial = {}; - - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const key = prop.name.getText(source); - - switch (key) { - case "type": { - if (ts.isPropertyAccessExpression(prop.initializer)) { - result.type = prop.initializer.getText(source); - } - break; - } - case "default": { - result.default = prop.initializer.getText(source); - break; - } - case "array": { - if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { - result.array = true; - } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { - result.array = false; - } - break; - } - case "nested": { - if (ts.isObjectLiteralExpression(prop.initializer)) { - result.nested = parseParamGroup(prop.initializer, source); - } - break; - } + const result: Partial = {}; + + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = prop.name.getText(source); + + switch (key) { + case "type": { + if (ts.isPropertyAccessExpression(prop.initializer)) { + result.type = prop.initializer.getText(source); + } + break; + } + case "default": { + result.default = prop.initializer.getText(source); + break; + } + case "array": { + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + result.array = true; + } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { + result.array = false; } + break; + } + case "nested": { + if (ts.isObjectLiteralExpression(prop.initializer)) { + result.nested = parseParamGroup(prop.initializer, source); + } + break; + } } + } - return result as ParameterInfo; + return result as ParameterInfo; } /** - * Extracts plugin information from a TypeScript AST. Source must already be + * Extracts plugin information from a TypeScript AST. Source must already be * transformed via the TypeScript compiler. Version and examples are not included - * in this function, but are gathered from the main CLI and from + * in this function, but are gathered from the main CLI and from * getPluginInfoAndExamples, respectively. - * + * * @param source TypeScript AST of the source file + * @param classNode the node representing the class declaration of the plugin * @returns a PluginInfo object containing name, description, parameters, and data. */ -export async function getPluginInfo(source: ts.SourceFile): Promise { - let result: PluginInfo = { - name: "", - description: "", - version: "", - parameters: {}, - data: {}, - examples: {}, - }; - - let classNode: ts.ClassDeclaration | undefined; - function visitClass(node: ts.Node) { - if (ts.isClassDeclaration(node)) { // check if class is a jsPsychPlugin - const implementsPlugin = node.heritageClauses?.some(h => - h.token === ts.SyntaxKind.ImplementsKeyword && - h.types.some(t => t.getText(source).includes("JsPsychPlugin")) - ); - if (implementsPlugin) classNode = node; - else - throw new Error("Plugin does not implement jsPsychPlugin interface. (how did we get here??)"); - } - ts.forEachChild(node, visitClass); - } - visitClass(source); - - if (classNode) { - const comment = extractJsDocComment(classNode, source); - if (comment) - result.description = comment; - else - console.warn("No JSDoc comment found for plugin class"); - } else { - throw new Error("Class does not properly implement jsPsychPlugin (how did we get here?)"); - } - - let infoNode: ts.ObjectLiteralExpression | undefined; - function visit(node: ts.Node) { - if (ts.isVariableDeclaration(node) && node.name.getText(source) === "info") { - let init = node.initializer; - // unwrap b/c of const assertion - if (init && ts.isTypeAssertionExpression(init)) - init = init.expression; - if (init && ts.isObjectLiteralExpression(init)) - infoNode = init; - } - ts.forEachChild(node, visit); +export async function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclaration): Promise { + let result: PluginInfo = { + name: "", + description: "", + version: "", + parameters: {}, + data: {}, + examples: {}, + }; + + const comment = extractJsDocComment(classNode, source); + if (comment) result.description = comment; + else console.warn("No JSDoc comment found for plugin class"); + + let infoNode: ts.ObjectLiteralExpression | undefined; + function visit(node: ts.Node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === "info") { + let init = node.initializer; + // unwrap b/c of const assertion + if (init && ts.isTypeAssertionExpression(init)) init = init.expression; + if (init && ts.isObjectLiteralExpression(init)) infoNode = init; } + ts.forEachChild(node, visit); + } - visit(source); + visit(source); - if (infoNode === undefined) { - throw new Error("Could not find info object in plugin file"); - } + if (infoNode === undefined) { + throw new Error("Could not find info object in plugin file"); + } - const nameProp = infoNode.properties.find( - (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "name" - ) as ts.PropertyAssignment | undefined; - if (nameProp && ts.isStringLiteral(nameProp.initializer)) { - result.name = nameProp.initializer.text; - } + const nameProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "name", + ) as ts.PropertyAssignment | undefined; + if (nameProp && ts.isStringLiteral(nameProp.initializer)) { + result.name = nameProp.initializer.text; + } - const parametersProp = infoNode.properties.find( - (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "parameters" - ) as ts.PropertyAssignment | undefined; + const parametersProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "parameters", + ) as ts.PropertyAssignment | undefined; - if (parametersProp && ts.isObjectLiteralExpression(parametersProp.initializer)) { - result.parameters = parseParamGroup(parametersProp.initializer, source); - } + if (parametersProp && ts.isObjectLiteralExpression(parametersProp.initializer)) { + result.parameters = parseParamGroup(parametersProp.initializer, source); + } - const dataProp = infoNode.properties.find( - (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "data" - ) as ts.PropertyAssignment | undefined; + const dataProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "data", + ) as ts.PropertyAssignment | undefined; - if (dataProp && ts.isObjectLiteralExpression(dataProp.initializer)) { - result.data = parseParamGroup(dataProp.initializer, source); - } + if (dataProp && ts.isObjectLiteralExpression(dataProp.initializer)) { + result.data = parseParamGroup(dataProp.initializer, source); + } - return result; + return result; } - /** * Fallback code block extractor for HTML example files without sentinels. Requires exactly * one inline script block (errors if zero or multiple are found). Parses the script with the @@ -165,197 +136,208 @@ export async function getPluginInfo(source: ts.SourceFile): Promise * the initializer and matching them against other locally declared variables. */ function inferCodeBlock(sourceContent: string, sourcePath: string): string { - const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; - const blocks: string[] = []; - let match: RegExpExecArray | null; - while ((match = scriptRegex.exec(sourceContent)) !== null) - blocks.push(match[1]); - - if (blocks.length === 0) - throw new Error(`${sourcePath}: no inline script blocks found`); - if (blocks.length > 1) - throw new Error(`${sourcePath}: multiple inline script blocks found — use jspsych-autodoc:start/end sentinels instead`); - - const scriptContent = blocks[0]; - const sourceFile = ts.createSourceFile("example.js", scriptContent, ts.ScriptTarget.Latest, true); - - const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; - const trialNodes: ts.VariableDeclaration[] = []; - - function visitTrials(node: ts.Node) { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && trialPattern.test(node.name.text)) { - if (ts.isObjectLiteralExpression(node.initializer!)) - trialNodes.push(node); - else - // TODO: explore support for non-object-literal trial initializers (e.g. buildTrial()) - throw new Error(`${sourcePath}: trial variable "${node.name.text}" has a non-object-literal initializer — use jspsych-autodoc:start/end sentinels instead`); - } - ts.forEachChild(node, visitTrials); + const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; + const blocks: string[] = []; + let match: RegExpExecArray | null; + while ((match = scriptRegex.exec(sourceContent)) !== null) blocks.push(match[1]); + + if (blocks.length === 0) throw new Error(`${sourcePath}: no inline script blocks found`); + if (blocks.length > 1) + throw new Error( + `${sourcePath}: multiple inline script blocks found, use jspsych-autodoc:start/end sentinels instead`, + ); + + const scriptContent = blocks[0]; + const sourceFile = ts.createSourceFile("example.js", scriptContent, ts.ScriptTarget.Latest, true); + + const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; + const trialNodes: ts.VariableDeclaration[] = []; + + function visitTrials(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + trialPattern.test(node.name.text) + ) { + if (ts.isObjectLiteralExpression(node.initializer!)) trialNodes.push(node); + else + // TODO: explore support for non-object-literal trial initializers (e.g. buildTrial()) + throw new Error( + `${sourcePath}: trial variable "${node.name.text}" has a non-object-literal initializer — use jspsych-autodoc:start/end sentinels instead`, + ); } - visitTrials(sourceFile); - - if (trialNodes.length === 0) - throw new Error(`${sourcePath}: no trial variables found — use jspsych-autodoc:start/end sentinels instead`); - - // build map of all local variable declarations, excluding trial nodes themselves - const localDecls = new Map(); - function visitDecls(node: ts.Node) { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { - const stmt = node.parent.parent; - if (ts.isVariableStatement(stmt)) - localDecls.set(node.name.text, stmt); - } - ts.forEachChild(node, visitDecls); + ts.forEachChild(node, visitTrials); + } + visitTrials(sourceFile); + + if (trialNodes.length === 0) + throw new Error( + `${sourcePath}: no trial variables found — use jspsych-autodoc:start/end sentinels instead`, + ); + + // build map of all local variable declarations, excluding trial nodes themselves + const localDecls = new Map(); + function visitDecls(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const stmt = node.parent.parent; + if (ts.isVariableStatement(stmt)) localDecls.set(node.name.text, stmt); } - visitDecls(sourceFile); - for (const trial of trialNodes) - localDecls.delete((trial.name as ts.Identifier).text); - - // Collect identifier references from a node, skipping property assignment keys - function collectIdentifiers(node: ts.Node, result: Set) { - if (ts.isPropertyAssignment(node)) { - collectIdentifiers(node.initializer, result); - return; - } - if (ts.isIdentifier(node)) { - result.add(node.text); - return; - } - ts.forEachChild(node, child => collectIdentifiers(child, result)); + ts.forEachChild(node, visitDecls); + } + visitDecls(sourceFile); + for (const trial of trialNodes) localDecls.delete((trial.name as ts.Identifier).text); + + // Collect identifier references from a node, skipping property assignment keys + function collectIdentifiers(node: ts.Node, result: Set) { + if (ts.isPropertyAssignment(node)) { + collectIdentifiers(node.initializer, result); + return; } - - // gather trial statements and their one-level dependencies, keyed by name to deduplicate - const outputStatements = new Map(); - for (const trial of trialNodes) { - const trialStmt = trial.parent.parent; - if (ts.isVariableStatement(trialStmt)) - outputStatements.set((trial.name as ts.Identifier).text, trialStmt); - - const refs = new Set(); - collectIdentifiers(trial.initializer!, refs); - for (const ref of refs) - if (localDecls.has(ref)) - outputStatements.set(ref, localDecls.get(ref)!); + if (ts.isIdentifier(node)) { + result.add(node.text); + return; } - - return Array.from(outputStatements.values()) - .sort((a, b) => a.pos - b.pos) - .map(node => node.getText(sourceFile).trim()) - .join("\n\n"); + ts.forEachChild(node, (child) => collectIdentifiers(child, result)); + } + + // gather trial statements and their one-level dependencies, keyed by name to deduplicate + const outputStatements = new Map(); + for (const trial of trialNodes) { + const trialStmt = trial.parent.parent; + if (ts.isVariableStatement(trialStmt)) + outputStatements.set((trial.name as ts.Identifier).text, trialStmt); + + const refs = new Set(); + collectIdentifiers(trial.initializer!, refs); + for (const ref of refs) + if (localDecls.has(ref)) outputStatements.set(ref, localDecls.get(ref)!); + } + + return Array.from(outputStatements.values()) + .sort((a, b) => a.pos - b.pos) + .map((node) => node.getText(sourceFile).trim()) + .join("\n\n"); } -/** - * Gets the example code block text from a given HTML file. Looks for sentinels first and +/** + * Gets the example code block text from a given HTML file. Looks for sentinels first and * orders sub-blocks via file position. Otherwise, infers based on trial variable declarations - * and their dependencies. + * and their dependencies. */ function getCodeBlock(sourceContent: string, sourcePath: string): string { - const START = "// jspsych-autodoc:start"; - const END = "// jspsych-autodoc:end"; - - type Marker = { type: "start" | "end"; pos: number }; - const markers: Marker[] = []; - - let i = 0; - while (i < sourceContent.length) { - const s = sourceContent.indexOf(START, i); - const e = sourceContent.indexOf(END, i); - if (s === -1 && e === -1) break; - if (s !== -1 && (e === -1 || s < e)) { - markers.push({ type: "start", pos: s }); - i = s + START.length; - } else { - markers.push({ type: "end", pos: e }); - i = e + END.length; - } - } - - if (markers.length === 0) - return inferCodeBlock(sourceContent, sourcePath); - - for (let j = 0; j < markers.length; j++) { - const expected = j % 2 === 0 ? "start" : "end"; - if (markers[j].type !== expected) - throw new Error(`${sourcePath}: mismatched jspsych-autodoc sentinels: unexpected ${markers[j].type} at marker ${j + 1}`); - } - if (markers.length % 2 !== 0) - throw new Error(`${sourcePath}: mismatched jspsych-autodoc sentinels: last start has no matching end`); - - const blocks: string[] = []; - for (let j = 0; j < markers.length; j += 2) { - const newlineAfterStart = sourceContent.indexOf("\n", markers[j].pos); - const blockStart = newlineAfterStart === -1 ? markers[j].pos + START.length : newlineAfterStart + 1; - const blockEnd = markers[j + 1].pos; - blocks.push(sourceContent.slice(blockStart, blockEnd).trimEnd()); + const START = "// jspsych-autodoc:start"; + const END = "// jspsych-autodoc:end"; + + type Marker = { type: "start" | "end"; pos: number }; + const markers: Marker[] = []; + + let i = 0; + while (i < sourceContent.length) { + const s = sourceContent.indexOf(START, i); + const e = sourceContent.indexOf(END, i); + if (s === -1 && e === -1) break; + if (s !== -1 && (e === -1 || s < e)) { + markers.push({ type: "start", pos: s }); + i = s + START.length; + } else { + markers.push({ type: "end", pos: e }); + i = e + END.length; } - - return blocks.join("\n\n"); + } + + if (markers.length === 0) return inferCodeBlock(sourceContent, sourcePath); + + for (let j = 0; j < markers.length; j++) { + const expected = j % 2 === 0 ? "start" : "end"; + if (markers[j].type !== expected) + throw new Error( + `${sourcePath}: mismatched jspsych-autodoc sentinels: unexpected ${markers[j].type} at marker ${j + 1}`, + ); + } + if (markers.length % 2 !== 0) + throw new Error( + `${sourcePath}: mismatched jspsych-autodoc sentinels: last start has no matching end`, + ); + + const blocks: string[] = []; + for (let j = 0; j < markers.length; j += 2) { + const newlineAfterStart = sourceContent.indexOf("\n", markers[j].pos); + const blockStart = + newlineAfterStart === -1 ? markers[j].pos + START.length : newlineAfterStart + 1; + const blockEnd = markers[j + 1].pos; + blocks.push(sourceContent.slice(blockStart, blockEnd).trimEnd()); + } + + return blocks.join("\n\n"); } /** Fetch example information from a given HTML filepath. `undefined` if the file is ignored * via sentinel . */ function getExampleInfo(sourcePath: string): Record | undefined { - const content = fs.readFileSync(sourcePath, "utf-8"); + const content = fs.readFileSync(sourcePath, "utf-8"); - if (//.test(content)) - return undefined; + if (//.test(content)) return undefined; - let title: string; + let title: string; - const sentinelMatch = content.match(//); - if (sentinelMatch) { - title = sentinelMatch[1].trim(); - } else { - const titleTagMatch = content.match(/([\s\S]*?)<\/title>/i); - if (!titleTagMatch) - throw new Error(`No title found in example file: ${sourcePath}`); - title = titleTagMatch[1].trim(); - } + const sentinelMatch = content.match(/<!--\s*jspsych-autodoc:title\s+(.+?)\s*-->/); + if (sentinelMatch) { + title = sentinelMatch[1].trim(); + } else { + const titleTagMatch = content.match(/<title>([\s\S]*?)<\/title>/i); + if (!titleTagMatch) throw new Error(`No title found in example file: ${sourcePath}`); + title = titleTagMatch[1].trim(); + } - return { - [title]: { path: sourcePath, code: getCodeBlock(content, sourcePath) } - }; + return { + [title]: { path: sourcePath, code: getCodeBlock(content, sourcePath) }, + }; } /** - * Extracts plugin information from a TypeScript AST. Source must already be + * Extracts plugin information from a TypeScript AST. Source must already be * transformed via the TypeScript compiler. Also gathers example information - * from the provided example path. - * + * from the provided example path. + * * @param source TypeScript AST of the source file + * @param classNode the node representing the class declaration of the plugin * @param examplePath Path to an example file or directory * @returns a PluginInfo object containing name, description, version, parameters, data, and examples. */ -export async function getPluginInfoAndExamples(source: ts.SourceFile, examplePath: string): Promise<PluginInfo> { - const info = await getPluginInfo(source); - - if (!fs.existsSync(examplePath)) { - throw new Error(`Example path does not exist: ${examplePath}`); +export async function getPluginInfoAndExamples( + source: ts.SourceFile, + classNode: ts.ClassDeclaration, + examplePath: string, +): Promise<PluginInfo> { + const info = await getPluginInfo(source, classNode); + + if (!fs.existsSync(examplePath)) { + throw new Error(`Example path does not exist: ${examplePath}`); + } + + const stat = fs.statSync(examplePath); + const htmlFiles: string[] = []; + + if (stat.isDirectory()) { + htmlFiles.push( + ...fs + .readdirSync(examplePath) + .filter((f) => f.endsWith(".html")) + .map((f) => path.join(examplePath, f)), + ); + } else if (stat.isFile()) { + if (!examplePath.endsWith(".html")) { + throw new Error(`Example file must be an HTML file: ${examplePath}`); } + htmlFiles.push(examplePath); + } else { + throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); + } - const stat = fs.statSync(examplePath); - const htmlFiles: string[] = []; + for (const file of htmlFiles) { + const exampleInfo = getExampleInfo(file); + if (exampleInfo) Object.assign(info.examples, exampleInfo); + } - if (stat.isDirectory()) { - htmlFiles.push( - ...fs.readdirSync(examplePath) - .filter(f => f.endsWith(".html")) - .map(f => path.join(examplePath, f)) - ); - } else if (stat.isFile()) { - if (!examplePath.endsWith(".html")) { - throw new Error(`Example file must be an HTML file: ${examplePath}`); - } - htmlFiles.push(examplePath); - } else { - throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); - } - - for (const file of htmlFiles) { - const exampleInfo = getExampleInfo(file); - if (exampleInfo) - Object.assign(info.examples, exampleInfo); - } - - return info; -} \ No newline at end of file + return info; +} diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts new file mode 100644 index 0000000..2508a14 --- /dev/null +++ b/packages/autodoc/src/parsers/utils.ts @@ -0,0 +1,12 @@ +import ts from "typescript"; + +/** Grabs JSDoc comments from a node. */ +export function extractJsDocComment(node: ts.Node, source: ts.SourceFile): string | undefined { + const jsDoc = ts.getJSDocCommentsAndTags(node); + const rawComment = jsDoc[0] && ts.isJSDoc(jsDoc[0]) ? jsDoc[0].comment : undefined; + return ( + typeof rawComment === "string" ? rawComment : rawComment?.map((n) => n.getText(source)).join("") + ) + ?.replace(/\s*\n\s*/g, " ") + .trim(); +} diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index 2b6bc1d..2156316 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,103 +1,105 @@ import { ParameterInfo, PluginInfo, SectionTemplate } from "../types/info.js"; const stringifyTypeMap: Record<string, string> = { - "ParameterType.STRING": "string", - "ParameterType.INT": "integer", - "ParameterType.FLOAT": "float", - "ParameterType.BOOL": "boolean", - "ParameterType.FUNCTION": "function", - "ParameterType.KEY": "key", - "ParameterType.KEYS": "keys", - "ParameterType.SELECT": "selection", //TODO: infer type from options - "ParameterType.HTML_STRING": "HTML string", - "ParameterType.IMAGE": "image file", - "ParameterType.AUDIO": "audio file", - "ParameterType.VIDEO": "video file", - "ParameterType.OBJECT": "object", - "ParameterType.COMPLEX": "object", -} + "ParameterType.STRING": "string", + "ParameterType.INT": "integer", + "ParameterType.FLOAT": "float", + "ParameterType.BOOL": "boolean", + "ParameterType.FUNCTION": "function", + "ParameterType.KEY": "key", + "ParameterType.KEYS": "keys", + "ParameterType.SELECT": "selection", //TODO: infer type from options + "ParameterType.HTML_STRING": "HTML string", + "ParameterType.IMAGE": "image file", + "ParameterType.AUDIO": "audio file", + "ParameterType.VIDEO": "video file", + "ParameterType.OBJECT": "object", + "ParameterType.COMPLEX": "object", +}; const getTypeName = (type: string, array?: boolean): string => { - const baseType = stringifyTypeMap[type] || type; - return array ? `array of ${baseType}` : baseType; -} + const baseType = stringifyTypeMap[type] || type; + return array ? `array of ${baseType}` : baseType; +}; -const topParameterChart = -`| Parameter | Type | Default Value | Description | +const topParameterChart = `| Parameter | Type | Default Value | Description | | --------- | ---- | ------------- | ----------- |`; const renderNestedParameterDescription = (nested: Record<string, ParameterInfo>): string => { - const parts = Object.entries(nested).map(([name, param]) => { - if (!param.description) { - console.warn(`Warning: Nested parameter "${name}" is missing a description.`); - param.description = "No description provided."; - } - const desc = param.nested - ? `${param.description} ${renderNestedParameterDescription(param.nested)}` - : param.description; - return `\`${name}\`: ${desc}`; - }); - return `(${parts.join(", ")})`; -} + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested parameter "${name}" is missing a description.`); + param.description = "No description provided."; + } + const desc = param.nested + ? `${param.description} ${renderNestedParameterDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +}; const renderParameterRow = (name: string, parameter: ParameterInfo): string => { - if (!parameter.description) { - console.warn(`Warning: Parameter "${name}" is missing a description.`); - parameter.description = "No description provided."; - } - const defaultValue = !isNaN(parseFloat(parameter.default)) ? parameter.default : `\`${parameter.default}\``; - const description = parameter.nested - ? `${parameter.description} ${renderNestedParameterDescription(parameter.nested)}` - : parameter.description; - return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${defaultValue} | ${description} |`; -} + if (!parameter.description) { + console.warn(`Warning: Parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const defaultValue = !isNaN(parseFloat(parameter.default)) + ? parameter.default + : `\`${parameter.default}\``; + const description = parameter.nested + ? `${parameter.description} ${renderNestedParameterDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${defaultValue} | ${description} |`; +}; const renderNestedDataDescription = (nested: Record<string, ParameterInfo>): string => { - const parts = Object.entries(nested).map(([name, param]) => { - if (!param.description) { - console.warn(`Warning: Nested data parameter "${name}" is missing a description.`); - param.description = "No description provided."; - } - const desc = param.nested - ? `${param.description} ${renderNestedDataDescription(param.nested)}` - : param.description; - return `\`${name}\`: ${desc}`; - }); - return `(${parts.join(", ")})`; -} - -const renderDataRow = (name: string, parameter: ParameterInfo): string => { - if (!parameter.description) { - console.warn(`Warning: Data parameter "${name}" is missing a description.`); - parameter.description = "No description provided."; + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested data parameter "${name}" is missing a description.`); + param.description = "No description provided."; } - const value = parameter.nested - ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` - : parameter.description; - return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${value} |`; -} + const desc = param.nested + ? `${param.description} ${renderNestedDataDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +}; -const topDataChart = -`| Name | Type | Value | +const renderDataRow = (name: string, parameter: ParameterInfo): string => { + if (!parameter.description) { + console.warn(`Warning: Data parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const value = parameter.nested + ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${value} |`; +}; + +const topDataChart = `| Name | Type | Value | | ---- | ---- | ----- |`; const mainTemplate: SectionTemplate<PluginInfo>[] = [ - { - heading: "introduction", - render: info => { - return ` + { + heading: "introduction", + render: (info) => { + return ` # ${info.name} ${info.description} Current version: ${info.version}`.trim(); - } }, - { - heading: "parameters", - render: info => { - const rows = Object.entries(info.parameters).map(([name, param]) => renderParameterRow(name, param)).join("\n"); - return ` + }, + { + heading: "parameters", + render: (info) => { + const rows = Object.entries(info.parameters) + .map(([name, param]) => renderParameterRow(name, param)) + .join("\n"); + return ` ## Parameters In addition to the [parameters available in all plugins](https://www.jspsych.org/latest/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of \`undefined\` must be specified. Other parameters can be left unspecified if the default value is acceptable. @@ -105,13 +107,15 @@ In addition to the [parameters available in all plugins](https://www.jspsych.org ${topParameterChart} ${rows} `.trim(); - } }, - { - heading: "data", - render: info => { - const rows = Object.entries(info.data).map(([name, param]) => renderDataRow(name, param)).join("\n"); - return ` + }, + { + heading: "data", + render: (info) => { + const rows = Object.entries(info.data) + .map(([name, param]) => renderDataRow(name, param)) + .join("\n"); + return ` ## Data In addition to the [default data collected by all plugins](https://www.jspsych.org/latest/overview/plugins#data-collected-by-all-plugins), this plugin collects the following data for each trial. @@ -119,31 +123,36 @@ In addition to the [default data collected by all plugins](https://www.jspsych.o ${topDataChart} ${rows} `.trim(); - } }, - { - heading: "examples", - render: info => { - const sections = Object.entries(info.examples).map(([title, example]) => -`### ${title} (${example.path}) + }, + { + heading: "examples", + render: (info) => { + const sections = Object.entries(info.examples) + .map( + ([title, example]) => + `### ${title} (${example.path}) \`\`\`js ${example.code} -\`\`\`` - ).join("\n\n"); - return ` +\`\`\``, + ) + .join("\n\n"); + return ` ## Examples ${sections} `.trim(); - } - } -] + }, + }, +]; export async function getPluginDocs(info: PluginInfo): Promise<Record<string, string>> { - return Object.fromEntries(mainTemplate.map(section => { - const content = section.render(info); - const wrapped = `<!-- jspsych-autodocs:${section.heading}:start -->\n${content}\n<!-- jspsych-autodocs:${section.heading}:end -->`; - return [section.heading, wrapped]; - })); -} \ No newline at end of file + return Object.fromEntries( + mainTemplate.map((section) => { + const content = section.render(info); + const wrapped = `<!-- jspsych-autodocs:${section.heading}:start -->\n${content}\n<!-- jspsych-autodocs:${section.heading}:end -->`; + return [section.heading, wrapped]; + }), + ); +} diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts index ee35570..5e870d1 100644 --- a/packages/autodoc/src/utils.ts +++ b/packages/autodoc/src/utils.ts @@ -1,9 +1,13 @@ +import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; + /** * Updates sections of a file delimited by sentinel tags with new content from the docs object. * The docs object should have keys corresponding to section headings and thus sentinel tags in - * the file. If any sentinel tags are missing in the original file, an error will immediately be + * the file. If any sentinel tags are missing in the original file, an error will immediately be * thrown. - * + * * @param fileContent the content of the file to be updated * @param docs the documentation content to update the file with * @returns the updated file content @@ -23,7 +27,7 @@ export function updateDocSections(fileContent: string, docs: Record<string, stri if (!anyFound) { throw new Error( - "No sentinel tags found, is this a valid jsPsych autodoc target? If not, create a new file with the CLI to observe the structure." + "No sentinel tags found, is this a valid jsPsych autodoc target? If not, create a new file with the CLI to observe the structure.", ); } @@ -36,18 +40,18 @@ export function updateDocSections(fileContent: string, docs: Record<string, stri if (!startFound && !endFound) { errors.push( `${heading} sentinel start and end tag was not found.\n` + - `Insert ${startTag} before the heading to complete the tag\n` + - `Insert ${endTag} after the chart to complete the tag` + `Insert ${startTag} before the heading to complete the tag\n` + + `Insert ${endTag} after the chart to complete the tag`, ); } else if (!startFound) { errors.push( `${heading} sentinel start tag was not found.\n` + - `Insert ${startTag} before the heading to complete the tag` + `Insert ${startTag} before the heading to complete the tag`, ); } else if (!endFound) { errors.push( `${heading} sentinel end tag was not found.\n` + - `Insert ${endTag} after the chart to complete the tag` + `Insert ${endTag} after the chart to complete the tag`, ); } } @@ -67,3 +71,72 @@ export function updateDocSections(fileContent: string, docs: Record<string, stri return result; } + +//TODO: add timeline functionality +/** + * Identifies whether a source file contains a jsPsychPlugin/jsPsychExtension, returning the classNode and the type. + * + * @param source the AST of the source file + * @returns object containing the classNode (for use in extracting doc, so that + * getXXXInfo does not have to re-find) and the type of package (plugin/extension). + */ +export function identifyPackageType(source: ts.SourceFile): { + classNode: ts.ClassDeclaration; + type: "plugin" | "extension"; +} { + let result: { classNode: ts.ClassDeclaration; type: "plugin" | "extension" } | null = null; + + function visitClass(node: ts.Node) { + if (ts.isClassDeclaration(node)) { + const implementsPlugin = node.heritageClauses?.some( + (h) => + h.token === ts.SyntaxKind.ImplementsKeyword && + h.types.some((t) => t.getText(source).includes("JsPsychPlugin")), + ); + const implementsExtension = node.heritageClauses?.some( + (h) => + h.token === ts.SyntaxKind.ImplementsKeyword && + h.types.some((t) => t.getText(source).includes("JsPsychExtension")), + ); + if (implementsPlugin && implementsExtension) { + throw new Error( + "A class cannot implement both JsPsychPlugin and JsPsychExtension interfaces.", + ); + } + else if (implementsExtension) result = { classNode: node, type: "extension" }; + else if (implementsPlugin) result = { classNode: node, type: "plugin" }; + else + throw new Error( + "Class does not implement JsPsychPlugin or JsPsychExtension interfaces. Ensure your class implements the correct interface.", + ); + } + ts.forEachChild(node, visitClass); + } + visitClass(source); + + if (!result) { + throw new Error("No plugin or extension class found in source file."); + } + + return result; +} + +/** Gathers the version number from a package.json file found in the current working directory. */ +export function extractVersionFromPackageJson(): string { + try { + const pluginPackageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"), + ); + if (pluginPackageJson.version) { + return pluginPackageJson.version; + } else { + console.warn("Warning: No version field found in package.json."); + return "unknown version"; + } + } catch (err) { + console.warn( + "Warning: Could not read package.json to determine version. Ensure you are running the CLI in the directory that contains the package.json.", + ); + return "unknown version"; + } +} diff --git a/packages/autodoc/tests/cli.test.ts b/packages/autodoc/tests/cli.test.ts new file mode 100644 index 0000000..779aba3 --- /dev/null +++ b/packages/autodoc/tests/cli.test.ts @@ -0,0 +1,48 @@ +import { identifyPackageType } from "../src/utils.js"; +import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function loadFixture(relativePath: string) { + const fixturePath = path.resolve(__dirname, relativePath); + return ts.createSourceFile( + fixturePath, + fs.readFileSync(fixturePath, "utf-8"), + ts.ScriptTarget.Latest, + true + ); +} + +const pluginSource = loadFixture("fixtures/plugin/basic.ts"); +const extensionSource = loadFixture("fixtures/extension/basic.ts"); +const bothInterfacesSource = loadFixture("fixtures/utils/both-interfaces.ts"); +const noClassSource = loadFixture("fixtures/utils/no-class.ts"); + +describe("identifyPackageType", () => { + it("identifies plugin class", () => { + const result = identifyPackageType(pluginSource); + expect(result.type).toBe("plugin"); + expect(result.classNode.name?.text).toBe("TestPlugin"); + }); + + it("identifies extension class", () => { + const result = identifyPackageType(extensionSource); + expect(result.type).toBe("extension"); + expect(result.classNode.name?.text).toBe("TestExtension"); + }); + + it("throws if class implements both interfaces", () => { + expect(() => identifyPackageType(bothInterfacesSource)).toThrow( + "A class cannot implement both JsPsychPlugin and JsPsychExtension interfaces." + ); + }); + + it("throws if no class found in source file", () => { + expect(() => identifyPackageType(noClassSource)).toThrow( + "No plugin or extension class found in source file." + ); + }); +}); diff --git a/packages/autodoc/tests/fixtures/extension/basic.ts b/packages/autodoc/tests/fixtures/extension/basic.ts index e69de29..57613c6 100644 --- a/packages/autodoc/tests/fixtures/extension/basic.ts +++ b/packages/autodoc/tests/fixtures/extension/basic.ts @@ -0,0 +1,52 @@ +import { ParameterType, JsPsychExtension } from "../../utils.js"; + +interface InitializeParameters { + +} + +interface OnStartParameters { + +} + +interface OnLoadParameters { + +} + +interface OnFinishParameters { + +} + +/** A test jsPsych extension. */ +class TestExtension implements JsPsychExtension { + static info = { + name: "test-extension", + version: "1.0.0", + data: { + /** Data parameter description. */ + data_param: { + type: ParameterType.FLOAT, + }, + /** + * Multi-line data parameter description. + * It has two lines for data. + */ + double_data: { + type: ParameterType.BOOL, + }, + /** Now let's have a grid. */ + grid: { + type: ParameterType.COMPLEX, + nested: { + /** With an x-coordinate. */ + x_coord: { + type: ParameterType.FLOAT, + }, + /** And a y-coordinate. */ + y_coord: { + type: ParameterType.FLOAT, + } + } + } + } + } +} diff --git a/packages/autodoc/tests/fixtures/utils/both-interfaces.ts b/packages/autodoc/tests/fixtures/utils/both-interfaces.ts new file mode 100644 index 0000000..8b7dd5e --- /dev/null +++ b/packages/autodoc/tests/fixtures/utils/both-interfaces.ts @@ -0,0 +1,5 @@ +import { JsPsychPlugin, JsPsychExtension } from "../../utils.js"; + +class InvalidClass implements JsPsychPlugin<any>, JsPsychExtension { + trial(_display_element: HTMLElement, _trial: any): void {} +} diff --git a/packages/autodoc/tests/fixtures/utils/no-class.ts b/packages/autodoc/tests/fixtures/utils/no-class.ts new file mode 100644 index 0000000..aced88d --- /dev/null +++ b/packages/autodoc/tests/fixtures/utils/no-class.ts @@ -0,0 +1 @@ +export const burger = "yum"; diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index a0e0656..bd3468c 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -3,6 +3,7 @@ import ts from 'typescript'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { identifyPackageType } from '../../src/utils.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturePath = path.resolve(__dirname, '../fixtures/plugin/basic.ts'); @@ -15,17 +16,20 @@ const fixtureSource = ts.createSourceFile( describe('getPluginInfo', () => { it('extracts name', async () => { - const info = await getPluginInfo(fixtureSource); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfo(fixtureSource, classNode); expect(info.name).toBe('test-plugin'); }); it('extracts class JSDoc as description', async () => { - const info = await getPluginInfo(fixtureSource); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfo(fixtureSource, classNode); expect(info.description).toBe('A test jsPsych plugin.'); }); it('extracts parameters with types and descriptions', async () => { - const info = await getPluginInfo(fixtureSource); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfo(fixtureSource, classNode); expect(info.parameters.single.type).toBe('ParameterType.STRING'); expect(info.parameters.single.description).toBe('Single-line description.'); expect(info.parameters.double_double.type).toBe('ParameterType.INT'); @@ -33,12 +37,14 @@ describe('getPluginInfo', () => { }); it('extracts array flag on parameters', async () => { - const info = await getPluginInfo(fixtureSource); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfo(fixtureSource, classNode); expect(info.parameters.list_of_stimuli.array).toBe(true); }); it('extracts data parameters', async () => { - const info = await getPluginInfo(fixtureSource); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfo(fixtureSource, classNode); expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); expect(info.data.data_param.description).toBe('Data parameter description.'); expect(info.data.double_data.type).toBe('ParameterType.BOOL'); @@ -51,7 +57,8 @@ describe('getPluginInfoAndExamples', () => { it('should extract examples from provided file', async () => { const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); - const info = await getPluginInfoAndExamples(fixtureSource, filePath); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfoAndExamples(fixtureSource, classNode, filePath); expect(Object.keys(info.examples)).toHaveLength(1); expect(info.examples['simple sentinel example']).toBeDefined(); expect(info.examples['simple sentinel example'].path).toBe(filePath); @@ -61,7 +68,8 @@ describe('getPluginInfoAndExamples', () => { }); it('should extract examples from provided directory', async () => { - const info = await getPluginInfoAndExamples(fixtureSource, examplesDir); + const { classNode } = identifyPackageType(fixtureSource); + const info = await getPluginInfoAndExamples(fixtureSource, classNode, examplesDir); expect(Object.keys(info.examples)).toHaveLength(4); expect(info.examples['ignored example']).toBeUndefined(); diff --git a/packages/autodoc/tests/utils.ts b/packages/autodoc/tests/utils.ts index 1c02a8d..581ac30 100644 --- a/packages/autodoc/tests/utils.ts +++ b/packages/autodoc/tests/utils.ts @@ -5,3 +5,7 @@ export enum ParameterType { export interface JsPsychPlugin<T> { trial(display_element: HTMLElement, trial: T): void; } + +export interface JsPsychExtension { + +} From 2664e381014d31ed38f6c9bdb9581c8a728f9efa Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:26:51 -0400 Subject: [PATCH 03/26] finish autodoc extension parser + tests, remove async/awaits, other refactors --- packages/autodoc/jest.config.js | 2 +- packages/autodoc/src/cli.ts | 32 +++-- packages/autodoc/src/parsers/extension.ts | 92 +++++++++++- packages/autodoc/src/parsers/plugin.ts | 71 +-------- packages/autodoc/src/parsers/timeline.ts | 18 ++- packages/autodoc/src/parsers/utils.ts | 136 ++++++++++++++++++ packages/autodoc/src/renderers/extension.ts | 5 + packages/autodoc/src/renderers/plugin.ts | 2 +- packages/autodoc/src/renderers/utils.ts | 0 .../autodoc/tests/fixtures/extension/basic.ts | 111 +++++++++----- .../autodoc/tests/parsers/extension.test.ts | 94 ++++++++++++ packages/autodoc/tests/parsers/plugin.test.ts | 28 ++-- packages/autodoc/tests/tsconfig.json | 8 ++ packages/autodoc/tsconfig.json | 4 +- 14 files changed, 461 insertions(+), 142 deletions(-) create mode 100644 packages/autodoc/src/renderers/extension.ts create mode 100644 packages/autodoc/src/renderers/utils.ts create mode 100644 packages/autodoc/tests/parsers/extension.test.ts create mode 100644 packages/autodoc/tests/tsconfig.json diff --git a/packages/autodoc/jest.config.js b/packages/autodoc/jest.config.js index 5ca5aeb..a10c9f6 100644 --- a/packages/autodoc/jest.config.js +++ b/packages/autodoc/jest.config.js @@ -8,6 +8,6 @@ export default { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: './tsconfig.json' }], + '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: './tests/tsconfig.json' }], }, }; diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 233f841..3d52989 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -10,7 +10,9 @@ import { Command } from "commander"; import { extractVersionFromPackageJson, identifyPackageType, updateDocSections } from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; import { getPluginDocs } from "./renderers/plugin.js"; -import { PluginInfo } from "./types/info.js"; +import { ExtensionInfo, PluginInfo } from "./types/info.js"; +import { getExtensionInfo, getExtensionInfoAndExamples } from "./parsers/extension.js"; +import { getExtensionDocs } from "./renderers/extension.js"; // auto get version from package.json const __filename = fileURLToPath(import.meta.url); @@ -28,7 +30,7 @@ interface CliOptions { } // TODO: simulation mode-- detect if simulation mode is supported via these plugins. -async function main(options: CliOptions): Promise<void> { +function main(options: CliOptions): void { let sourcePath: string; if (options.source) { sourcePath = options.source; @@ -48,28 +50,42 @@ async function main(options: CliOptions): Promise<void> { ); const { classNode, type } = identifyPackageType(source); + console.log(type) let docs: Record<string, string>; if (type === "extension") { - throw new Error("Extension autodocs are not yet supported. Please use the autodoc CLI with a plugin source file."); + console.log("Identified package type: extension"); + + let extensionInfo: ExtensionInfo; + if (options.example) { + extensionInfo = getExtensionInfoAndExamples(source, classNode, options.example); + } else { + extensionInfo = getExtensionInfo(source, classNode); + } + + console.log(extensionInfo); + return; + + extensionInfo.version = extractVersionFromPackageJson(); + + docs = getExtensionDocs(extensionInfo); } else if (type === "plugin") { console.log("Identified package type: plugin"); let pluginInfo: PluginInfo; if (options.example) { - pluginInfo = await getPluginInfoAndExamples(source, classNode, options.example); + pluginInfo = getPluginInfoAndExamples(source, classNode, options.example); } else { - pluginInfo = await getPluginInfo(source, classNode); + pluginInfo = getPluginInfo(source, classNode); } pluginInfo.version = extractVersionFromPackageJson(); - docs = await getPluginDocs(pluginInfo); + docs = getPluginDocs(pluginInfo); } else { throw new Error("Unrecognized package type."); } - const rawContent = Object.values(docs).join("\n\n"); if (!fs.existsSync(options.dest)) { fs.writeFileSync(options.dest, rawContent, "utf8"); @@ -110,4 +126,4 @@ Examples: program.parse(); const options = program.opts<CliOptions>(); -await main(options); +main(options); diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 263448b..210ff40 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -1,8 +1,13 @@ import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; import { ExtensionInfo } from "../types/info.js"; +import { extractJsDocComment, parseParamGroup, parseTSParamGroup } from "./utils.js"; -export async function getExtensionInfo(source: ts.SourceFile): Promise<ExtensionInfo> { - // Placeholder implementation, replace with actual logic to retrieve documentation +export function getExtensionInfo( + source: ts.SourceFile, + classNode: ts.ClassDeclaration, +): ExtensionInfo { let result: ExtensionInfo = { name: "", description: "", @@ -15,15 +20,90 @@ export async function getExtensionInfo(source: ts.SourceFile): Promise<Extension examples: {}, }; + const comment = extractJsDocComment(classNode, source); + if (comment) result.description = comment; + else console.warn("No JSDoc comment found for extension class"); + + let infoNode: ts.ObjectLiteralExpression | undefined; + for (const member of classNode.members) { + if ( + ts.isPropertyDeclaration(member) && + member.name.getText(source) === "info" && + member.initializer && + ts.isObjectLiteralExpression(member.initializer) + ) { + infoNode = member.initializer; + break; + } + } + + if (infoNode === undefined) { + throw new Error("Could not find info object in extension file."); + } + + const nameProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "name", + ) as ts.PropertyAssignment | undefined; + if (nameProp && ts.isStringLiteral(nameProp.initializer)) { + result.name = nameProp.initializer.text; + } + + const dataProp = infoNode.properties.find( + (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "data", + ) as ts.PropertyAssignment | undefined; + if (dataProp && ts.isObjectLiteralExpression(dataProp.initializer)) { + result.data = parseParamGroup(dataProp.initializer, source); + } + + const paramInterfaces: Record<string, keyof ExtensionInfo> = { + InitializeParameters: "initializeParameters", + OnStartParameters: "onStartParameters", + OnLoadParameters: "onLoadParameters", + OnFinishParameters: "onFinishParameters", + }; + + for (const statement of source.statements) { + if (ts.isInterfaceDeclaration(statement)) { + const field = paramInterfaces[statement.name.text]; + if (field) { + (result[field] as Record<string, unknown>) = parseTSParamGroup(statement, source); + } + } + } + return result; } -export async function getExtensionInfoAndExamples( +export function getExtensionInfoAndExamples( source: ts.SourceFile, + classNode: ts.ClassDeclaration, examplePath: string, -): Promise<ExtensionInfo> { - // Placeholder implementation, replace with actual logic to retrieve documentation - const info = await getExtensionInfo(source); +): ExtensionInfo { + const info = getExtensionInfo(source, classNode); + + if (!fs.existsSync(examplePath)) { + throw new Error(`Example path does not exist: ${examplePath}`); + } + + const stat = fs.statSync(examplePath); + const htmlFiles: string[] = []; + + if (stat.isDirectory()) { + htmlFiles.push( + ...fs + .readdirSync(examplePath) + .filter((f) => f.endsWith(".html")) + .map((f) => path.join(examplePath, f)), + ); + } else if (stat.isFile()) { + if (!examplePath.endsWith(".html")) { + throw new Error(`Example file must be an HTML file: ${examplePath}`); + } + htmlFiles.push(examplePath); + } else { + throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); + } + // TODO: actually implement this return info; } diff --git a/packages/autodoc/src/parsers/plugin.ts b/packages/autodoc/src/parsers/plugin.ts index 6e06a1d..ee60212 100644 --- a/packages/autodoc/src/parsers/plugin.ts +++ b/packages/autodoc/src/parsers/plugin.ts @@ -2,64 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; import { PluginInfo, ParameterInfo, ExampleInfo } from "../types/info.js"; -import { extractJsDocComment } from "./utils.js"; - -/** Parses one parameter from an parameter node. */ -function parseParamGroup( - node: ts.ObjectLiteralExpression, - source: ts.SourceFile, -): Record<string, ParameterInfo> { - const result: Record<string, ParameterInfo> = {}; - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - if (!ts.isObjectLiteralExpression(prop.initializer)) continue; - const name = prop.name.getText(source); - const info = extractParameter(prop.initializer, source); - const comment = extractJsDocComment(prop, source); - if (comment) info.description = comment; - result[name] = info; - } - return result; -} - -/** Gathers parameter information (type, default, etc.) from a node. */ -function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFile): ParameterInfo { - const result: Partial<ParameterInfo> = {}; - - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const key = prop.name.getText(source); - - switch (key) { - case "type": { - if (ts.isPropertyAccessExpression(prop.initializer)) { - result.type = prop.initializer.getText(source); - } - break; - } - case "default": { - result.default = prop.initializer.getText(source); - break; - } - case "array": { - if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { - result.array = true; - } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { - result.array = false; - } - break; - } - case "nested": { - if (ts.isObjectLiteralExpression(prop.initializer)) { - result.nested = parseParamGroup(prop.initializer, source); - } - break; - } - } - } - - return result as ParameterInfo; -} +import { extractJsDocComment, parseParamGroup } from "./utils.js"; /** * Extracts plugin information from a TypeScript AST. Source must already be @@ -71,7 +14,7 @@ function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFil * @param classNode the node representing the class declaration of the plugin * @returns a PluginInfo object containing name, description, parameters, and data. */ -export async function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclaration): Promise<PluginInfo> { +export function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclaration): PluginInfo { let result: PluginInfo = { name: "", description: "", @@ -99,7 +42,7 @@ export async function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDe visit(source); if (infoNode === undefined) { - throw new Error("Could not find info object in plugin file"); + throw new Error("Could not find info object in plugin file."); } const nameProp = infoNode.properties.find( @@ -112,7 +55,6 @@ export async function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDe const parametersProp = infoNode.properties.find( (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "parameters", ) as ts.PropertyAssignment | undefined; - if (parametersProp && ts.isObjectLiteralExpression(parametersProp.initializer)) { result.parameters = parseParamGroup(parametersProp.initializer, source); } @@ -120,7 +62,6 @@ export async function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDe const dataProp = infoNode.properties.find( (p) => ts.isPropertyAssignment(p) && p.name.getText(source) === "data", ) as ts.PropertyAssignment | undefined; - if (dataProp && ts.isObjectLiteralExpression(dataProp.initializer)) { result.data = parseParamGroup(dataProp.initializer, source); } @@ -304,12 +245,12 @@ function getExampleInfo(sourcePath: string): Record<string, ExampleInfo> | undef * @param examplePath Path to an example file or directory * @returns a PluginInfo object containing name, description, version, parameters, data, and examples. */ -export async function getPluginInfoAndExamples( +export function getPluginInfoAndExamples( source: ts.SourceFile, classNode: ts.ClassDeclaration, examplePath: string, -): Promise<PluginInfo> { - const info = await getPluginInfo(source, classNode); +): PluginInfo { + const info = getPluginInfo(source, classNode); if (!fs.existsSync(examplePath)) { throw new Error(`Example path does not exist: ${examplePath}`); diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts index 43ea6c3..400e665 100644 --- a/packages/autodoc/src/parsers/timeline.ts +++ b/packages/autodoc/src/parsers/timeline.ts @@ -1,10 +1,8 @@ - - -export async function getTimelineInfo(info: any): Promise<string[]> { - // Placeholder implementation, replace with actual logic to retrieve documentation - return [ - `Documentation for timeline: ${info.name}`, - `Description: ${info.description}`, - `Version: ${info.version}`, - ]; -} \ No newline at end of file +export function getTimelineInfo(info: any): string[] { + // Placeholder implementation, replace with actual logic to retrieve documentation + return [ + `Documentation for timeline: ${info.name}`, + `Description: ${info.description}`, + `Version: ${info.version}`, + ]; +} diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 2508a14..c129c3f 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -1,4 +1,8 @@ import ts from "typescript"; +import { ParameterInfo } from "../types/info.js"; + +/** thing that removes comments from nested parameter types */ +const printer = ts.createPrinter({ removeComments: true }); /** Grabs JSDoc comments from a node. */ export function extractJsDocComment(node: ts.Node, source: ts.SourceFile): string | undefined { @@ -10,3 +14,135 @@ export function extractJsDocComment(node: ts.Node, source: ts.SourceFile): strin ?.replace(/\s*\n\s*/g, " ") .trim(); } + +/** Parses one parameter from an parameter node. */ +export function parseParamGroup( + node: ts.ObjectLiteralExpression, + source: ts.SourceFile, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isObjectLiteralExpression(prop.initializer)) continue; + const name = prop.name.getText(source); + const info = extractParameter(prop.initializer, source); + const comment = extractJsDocComment(prop, source); + if (comment) info.description = comment; + result[name] = info; + } + return result; +} + + +/** Gathers parameter information (type, default, etc.) from a node. */ +function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFile): ParameterInfo { + const result: Partial<ParameterInfo> = {}; + + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = prop.name.getText(source); + + switch (key) { + case "type": { + if (ts.isPropertyAccessExpression(prop.initializer)) { + result.type = prop.initializer.getText(source); + } + break; + } + case "default": { + result.default = prop.initializer.getText(source); + break; + } + case "array": { + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + result.array = true; + } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) { + result.array = false; + } + break; + } + case "nested": { + if (ts.isObjectLiteralExpression(prop.initializer)) { + result.nested = parseParamGroup(prop.initializer, source); + } + break; + } + } + } + + return result as ParameterInfo; +} + +/** Parses the extension parameter interface signatures. */ +export function parseTSParamGroup( + node: ts.InterfaceDeclaration, + source: ts.SourceFile, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + for (const member of node.members) { + if (!ts.isPropertySignature(member)) continue; + result[member.name.getText(source)] = parseTSProperty(member, source); + } + for (const [name, info] of Object.entries(result)) { + const rootHasDefault = !!info.default; + const childHasDefault = !!info.nested && Object.values(info.nested).some((n) => !!n.default); + if (!rootHasDefault && !childHasDefault) { + console.warn(`Warning: No @default tag found for parameter "${name}" or any of its nested children.`); + } + } + return result; +} + +/** Parses an inline type literal's members into ParameterInfo records. */ +function parseTSTypeLiteral( + node: ts.TypeLiteralNode, + source: ts.SourceFile, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + for (const member of node.members) { + if (!ts.isPropertySignature(member)) continue; + result[member.name.getText(source)] = parseTSProperty(member, source); + } + return result; +} + +/** Parses a single property signature into a ParameterInfo. */ +function parseTSProperty( + member: ts.PropertySignature, + source: ts.SourceFile, +): ParameterInfo { + const name = member.name.getText(source); + const info: Partial<ParameterInfo> = {}; + + if (member.type) { + let typeNode: ts.TypeNode = member.type; + if (ts.isArrayTypeNode(typeNode)) { + info.array = true; + typeNode = typeNode.elementType; + } + info.type = printer.printNode(ts.EmitHint.Unspecified, typeNode, source) + .replace(/\s*\n\s*/g, " ") + .replace(/; \}/g, " }") // remove end ; } -> } for complex types + .replace(/; /g, ", ") // replace semicolon w comma to make it JSON-like + .trim(); + if (ts.isTypeLiteralNode(typeNode)) { + info.nested = parseTSTypeLiteral(typeNode, source); + } + } else { + console.warn(`Warning: No type annotation found for parameter "${name}".`); + info.type = "unknown type"; + } + + const description = extractJsDocComment(member, source); + if (description) info.description = description; + + const defaultTag = ts.getJSDocTags(member).find((t) => t.tagName.text === "default"); + if (defaultTag) { + const raw = defaultTag.comment; + info.default = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join("") + )?.trim(); + } + + return info as ParameterInfo; +} \ No newline at end of file diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts new file mode 100644 index 0000000..29b51db --- /dev/null +++ b/packages/autodoc/src/renderers/extension.ts @@ -0,0 +1,5 @@ +import { ExtensionInfo } from "../types/info.js"; + +export function getExtensionDocs(info: ExtensionInfo): Record<string, string> { + return {}; +} diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index 2156316..aa96ab0 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -147,7 +147,7 @@ ${sections} }, ]; -export async function getPluginDocs(info: PluginInfo): Promise<Record<string, string>> { +export function getPluginDocs(info: PluginInfo): Record<string, string> { return Object.fromEntries( mainTemplate.map((section) => { const content = section.render(info); diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/autodoc/tests/fixtures/extension/basic.ts b/packages/autodoc/tests/fixtures/extension/basic.ts index 57613c6..a2d59ee 100644 --- a/packages/autodoc/tests/fixtures/extension/basic.ts +++ b/packages/autodoc/tests/fixtures/extension/basic.ts @@ -1,52 +1,93 @@ import { ParameterType, JsPsychExtension } from "../../utils.js"; interface InitializeParameters { - + /** + * Single-line description. + * @default 0 + */ + single: number; + /** + * Multi-line description. It has two lines for a parameter. + * Woo. + * + * @default "hello hello" + */ + double_double: string; + /** + * List of stimuli. + * + * @default ["stim1.png", "stim2.png"] + */ + list_of_stimuli: string[]; } interface OnStartParameters { - + /** + * Let's try an object. + * + * @default { nested_param: 42 } + */ + nested_object: { nested_param: number }; } interface OnLoadParameters { - + /** + * Maybe a grid. + */ + grid: { + /** + * The x coordinate. + * @default 3 + */ + x: number; + /** + * The y coordinate. + * @default 3 + */ + y: number; + }; } interface OnFinishParameters { - + /** + * And a boolean grid. + * + * @default [[true, false], [false, true]] + */ + boolean_param: boolean[][]; } /** A test jsPsych extension. */ class TestExtension implements JsPsychExtension { - static info = { - name: "test-extension", - version: "1.0.0", - data: { - /** Data parameter description. */ - data_param: { - type: ParameterType.FLOAT, - }, - /** - * Multi-line data parameter description. - * It has two lines for data. - */ - double_data: { - type: ParameterType.BOOL, - }, - /** Now let's have a grid. */ - grid: { - type: ParameterType.COMPLEX, - nested: { - /** With an x-coordinate. */ - x_coord: { - type: ParameterType.FLOAT, - }, - /** And a y-coordinate. */ - y_coord: { - type: ParameterType.FLOAT, - } - } - } - } - } + static info = { + name: "test-extension", + version: "1.0.0", + data: { + /** Data parameter description. */ + data_param: { + type: ParameterType.FLOAT, + }, + /** + * Multi-line data parameter description. + * It has two lines for data. + */ + double_data: { + type: ParameterType.BOOL, + }, + /** Now let's have a grid. */ + grid: { + type: ParameterType.COMPLEX, + nested: { + /** With an x-coordinate. */ + x_coord: { + type: ParameterType.FLOAT, + }, + /** And a y-coordinate. */ + y_coord: { + type: ParameterType.FLOAT, + }, + }, + }, + }, + }; } diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts new file mode 100644 index 0000000..643b4bb --- /dev/null +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -0,0 +1,94 @@ +import { getExtensionInfo } from '../../src/parsers/extension.js'; +import ts from 'typescript'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { identifyPackageType } from '../../src/utils.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.resolve(__dirname, '../fixtures/extension/basic.ts'); +const fixtureSource = ts.createSourceFile( + fixturePath, + fs.readFileSync(fixturePath, 'utf-8'), + ts.ScriptTarget.Latest, + true +); + +describe('getExtensionInfo', () => { + it('extracts name', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.name).toBe('test-extension'); + }); + + it('extracts class JSDoc as description', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.description).toBe('A test jsPsych extension.'); + }); + + it('extracts initializeParameters with types, descriptions, and defaults', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.initializeParameters.single.type).toBe('number'); + expect(info.initializeParameters.single.description).toBe('Single-line description.'); + expect(info.initializeParameters.single.default).toBe('0'); + expect(info.initializeParameters.double_double.type).toBe('string'); + expect(info.initializeParameters.double_double.description).toBe('Multi-line description. It has two lines for a parameter. Woo.'); + expect(info.initializeParameters.double_double.default).toBe('"hello hello"'); + }); + + it('extracts array flag on initializeParameters', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.initializeParameters.list_of_stimuli.array).toBe(true); + expect(info.initializeParameters.list_of_stimuli.type).toBe('string'); + expect(info.initializeParameters.list_of_stimuli.default).toBe('["stim1.png", "stim2.png"]'); + }); + + it('extracts onStartParameters with object type', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.onStartParameters.nested_object.type).toBe('{ nested_param: number }'); + expect(info.onStartParameters.nested_object.description).toBe("Let's try an object."); + expect(info.onStartParameters.nested_object.default).toBe('{ nested_param: 42 }'); + }); + + it('extracts onLoadParameters with nested descriptions and defaults', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.onLoadParameters.grid.description).toBe('Maybe a grid.'); + expect(info.onLoadParameters.grid.default).toBeUndefined(); + expect(info.onLoadParameters.grid.nested).toBeDefined(); + expect(info.onLoadParameters.grid.nested!.x.description).toBe('The x coordinate.'); + expect(info.onLoadParameters.grid.nested!.x.default).toBe('3'); + expect(info.onLoadParameters.grid.nested!.y.description).toBe('The y coordinate.'); + expect(info.onLoadParameters.grid.nested!.y.default).toBe('3'); + }); + + it('extracts onFinishParameters with nested array type', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.onFinishParameters.boolean_param.array).toBe(true); + expect(info.onFinishParameters.boolean_param.type).toBe('boolean[]'); + expect(info.onFinishParameters.boolean_param.description).toBe('And a boolean grid.'); + expect(info.onFinishParameters.boolean_param.default).toBe('[[true, false], [false, true]]'); + }); + + it('extracts data parameters', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); + expect(info.data.data_param.description).toBe('Data parameter description.'); + expect(info.data.double_data.type).toBe('ParameterType.BOOL'); + expect(info.data.double_data.description).toBe('Multi-line data parameter description. It has two lines for data.'); + }); + + it('extracts nested data parameters', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode); + expect(info.data.grid.type).toBe('ParameterType.COMPLEX'); + expect(info.data.grid.description).toBe("Now let's have a grid."); + expect(info.data.grid.nested).toBeDefined(); + }); +}); diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index bd3468c..9723fa4 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -15,36 +15,36 @@ const fixtureSource = ts.createSourceFile( ); describe('getPluginInfo', () => { - it('extracts name', async () => { + it('extracts name', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfo(fixtureSource, classNode); + const info = getPluginInfo(fixtureSource, classNode); expect(info.name).toBe('test-plugin'); }); - it('extracts class JSDoc as description', async () => { + it('extracts class JSDoc as description', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfo(fixtureSource, classNode); + const info = getPluginInfo(fixtureSource, classNode); expect(info.description).toBe('A test jsPsych plugin.'); }); - it('extracts parameters with types and descriptions', async () => { + it('extracts parameters with types and descriptions', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfo(fixtureSource, classNode); + const info = getPluginInfo(fixtureSource, classNode); expect(info.parameters.single.type).toBe('ParameterType.STRING'); expect(info.parameters.single.description).toBe('Single-line description.'); expect(info.parameters.double_double.type).toBe('ParameterType.INT'); expect(info.parameters.double_double.description).toBe('Multi-line description. It has two lines for a parameter.'); }); - it('extracts array flag on parameters', async () => { + it('extracts array flag on parameters', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfo(fixtureSource, classNode); + const info = getPluginInfo(fixtureSource, classNode); expect(info.parameters.list_of_stimuli.array).toBe(true); }); - it('extracts data parameters', async () => { + it('extracts data parameters', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfo(fixtureSource, classNode); + const info = getPluginInfo(fixtureSource, classNode); expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); expect(info.data.data_param.description).toBe('Data parameter description.'); expect(info.data.double_data.type).toBe('ParameterType.BOOL'); @@ -55,10 +55,10 @@ describe('getPluginInfo', () => { describe('getPluginInfoAndExamples', () => { const examplesDir = path.resolve(__dirname, '../fixtures/plugin/examples'); - it('should extract examples from provided file', async () => { + it('should extract examples from provided file', () => { const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfoAndExamples(fixtureSource, classNode, filePath); + const info = getPluginInfoAndExamples(fixtureSource, classNode, filePath); expect(Object.keys(info.examples)).toHaveLength(1); expect(info.examples['simple sentinel example']).toBeDefined(); expect(info.examples['simple sentinel example'].path).toBe(filePath); @@ -67,9 +67,9 @@ describe('getPluginInfoAndExamples', () => { ); }); - it('should extract examples from provided directory', async () => { + it('should extract examples from provided directory', () => { const { classNode } = identifyPackageType(fixtureSource); - const info = await getPluginInfoAndExamples(fixtureSource, classNode, examplesDir); + const info = getPluginInfoAndExamples(fixtureSource, classNode, examplesDir); expect(Object.keys(info.examples)).toHaveLength(4); expect(info.examples['ignored example']).toBeUndefined(); diff --git a/packages/autodoc/tests/tsconfig.json b/packages/autodoc/tests/tsconfig.json new file mode 100644 index 0000000..20c8bb7 --- /dev/null +++ b/packages/autodoc/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["../src", "."] +} diff --git a/packages/autodoc/tsconfig.json b/packages/autodoc/tsconfig.json index 73d8748..249ed50 100644 --- a/packages/autodoc/tsconfig.json +++ b/packages/autodoc/tsconfig.json @@ -4,7 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "rootDir": ".", + "rootDir": "src", "strict": true, "resolveJsonModule": true, "esModuleInterop": true, @@ -14,6 +14,6 @@ "types": ["jest", "node"], "isolatedModules": true }, - "include": ["src", "tests", "package.json"], + "include": ["src"], "exclude": ["node_modules", "dist"] } From 23f926c9b28f0493d375b8f7b8fe7b551724ac54 Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:12:44 -0400 Subject: [PATCH 04/26] quick and dirty implementation of extension renderer --- packages/autodoc/src/cli.ts | 3 - packages/autodoc/src/renderers/extension.ts | 95 ++++++++++++++++++++- packages/autodoc/src/renderers/plugin.ts | 82 +++--------------- packages/autodoc/src/renderers/utils.ts | 60 +++++++++++++ 4 files changed, 162 insertions(+), 78 deletions(-) diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 3d52989..583e0e0 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -63,9 +63,6 @@ function main(options: CliOptions): void { extensionInfo = getExtensionInfo(source, classNode); } - console.log(extensionInfo); - return; - extensionInfo.version = extractVersionFromPackageJson(); docs = getExtensionDocs(extensionInfo); diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index 29b51db..d9d500a 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -1,5 +1,94 @@ -import { ExtensionInfo } from "../types/info.js"; +import { ExtensionInfo, SectionTemplate } from "../types/info.js"; +import { renderParameterRow, renderDataRow, topParameterChart, topDataChart } from "./utils.js"; -export function getExtensionDocs(info: ExtensionInfo): Record<string, string> { - return {}; +const getTypeName = (type: string, array?: boolean): string => + array ? `array of ${type}` : type; + +const toJsPsychExtensionName = (name: string): string => + "jsPsychExtension" + name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(""); + +const mainTemplate: SectionTemplate<ExtensionInfo>[] = [ + { + heading: "introduction", + render: (info) => { + return `# ${info.name} + +${info.description} + +Current version: ${info.version}`.trim(); + }, + }, + { + heading: "parameters", + render: (_) => `## Parameters \n` + }, + { + heading: "init-parameters", + render: (info) => { + const initRows = Object.entries(info.initializeParameters) + .map(([name, param]) => renderParameterRow(name, param, getTypeName)) + .join("\n"); + return `### Initialization Parameters +Initialization parameters are set when calling \`initJsPsych()\`. + +\`\`\`js +initJsPsych({ + extensions: { + { type: ${toJsPsychExtensionName(info.name)}, params: { ... } } + } +}) +\`\`\` + +${topParameterChart} +${initRows ?? "*None*"} +`.trim(); + } + }, + { + heading: "trial-parameters", + render: (info) => { + const trialRows = Object.entries({ ...info.onStartParameters, ...info.onLoadParameters, ...info.onFinishParameters }) + .map(([name, param]) => renderParameterRow(name, param, getTypeName)) + .join("\n"); + return `### Trial Parameters + +Trial parameters are set when adding an extension to the trial object. + +\`\`\`js +var trial = { + type: jsPsych..., + extensions: [ + { type: ${toJsPsychExtensionName(info.name)}, params: { ... } } + ] } +\`\`\` + +${topParameterChart} +${trialRows ?? "*None*"} +`.trim(); + } + }, + { + heading: "data", + render: (info) => { + const rows = Object.entries(info.data) + .map(([name, param]) => renderDataRow(name, param, getTypeName)) + .join("\n"); + return `## Data Generated + +${topDataChart} +${rows ?? "*None*"} +`.trim(); + }, + } +] + +export function getExtensionDocs(info: ExtensionInfo): Record<string, string> { + return Object.fromEntries( + mainTemplate.map((section) => { + const content = section.render(info); + const wrapped = `<!-- jspsych-autodocs:${section.heading}:start -->\n${content}\n<!-- jspsych-autodocs:${section.heading}:end -->`; + return [section.heading, wrapped]; + }), + ); +} \ No newline at end of file diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index aa96ab0..59da06a 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,4 +1,5 @@ -import { ParameterInfo, PluginInfo, SectionTemplate } from "../types/info.js"; +import { PluginInfo, SectionTemplate } from "../types/info.js"; +import { renderParameterRow, renderDataRow, topParameterChart, topDataChart } from "./utils.js"; const stringifyTypeMap: Record<string, string> = { "ParameterType.STRING": "string", @@ -22,71 +23,11 @@ const getTypeName = (type: string, array?: boolean): string => { return array ? `array of ${baseType}` : baseType; }; -const topParameterChart = `| Parameter | Type | Default Value | Description | -| --------- | ---- | ------------- | ----------- |`; - -const renderNestedParameterDescription = (nested: Record<string, ParameterInfo>): string => { - const parts = Object.entries(nested).map(([name, param]) => { - if (!param.description) { - console.warn(`Warning: Nested parameter "${name}" is missing a description.`); - param.description = "No description provided."; - } - const desc = param.nested - ? `${param.description} ${renderNestedParameterDescription(param.nested)}` - : param.description; - return `\`${name}\`: ${desc}`; - }); - return `(${parts.join(", ")})`; -}; - -const renderParameterRow = (name: string, parameter: ParameterInfo): string => { - if (!parameter.description) { - console.warn(`Warning: Parameter "${name}" is missing a description.`); - parameter.description = "No description provided."; - } - const defaultValue = !isNaN(parseFloat(parameter.default)) - ? parameter.default - : `\`${parameter.default}\``; - const description = parameter.nested - ? `${parameter.description} ${renderNestedParameterDescription(parameter.nested)}` - : parameter.description; - return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${defaultValue} | ${description} |`; -}; - -const renderNestedDataDescription = (nested: Record<string, ParameterInfo>): string => { - const parts = Object.entries(nested).map(([name, param]) => { - if (!param.description) { - console.warn(`Warning: Nested data parameter "${name}" is missing a description.`); - param.description = "No description provided."; - } - const desc = param.nested - ? `${param.description} ${renderNestedDataDescription(param.nested)}` - : param.description; - return `\`${name}\`: ${desc}`; - }); - return `(${parts.join(", ")})`; -}; - -const renderDataRow = (name: string, parameter: ParameterInfo): string => { - if (!parameter.description) { - console.warn(`Warning: Data parameter "${name}" is missing a description.`); - parameter.description = "No description provided."; - } - const value = parameter.nested - ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` - : parameter.description; - return `| ${name} | ${getTypeName(parameter.type, parameter.array)} | ${value} |`; -}; - -const topDataChart = `| Name | Type | Value | -| ---- | ---- | ----- |`; - const mainTemplate: SectionTemplate<PluginInfo>[] = [ { heading: "introduction", render: (info) => { - return ` -# ${info.name} + return `# ${info.name} ${info.description} @@ -97,15 +38,14 @@ Current version: ${info.version}`.trim(); heading: "parameters", render: (info) => { const rows = Object.entries(info.parameters) - .map(([name, param]) => renderParameterRow(name, param)) + .map(([name, param]) => renderParameterRow(name, param, getTypeName)) .join("\n"); - return ` -## Parameters + return `## Parameters In addition to the [parameters available in all plugins](https://www.jspsych.org/latest/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of \`undefined\` must be specified. Other parameters can be left unspecified if the default value is acceptable. ${topParameterChart} -${rows} +${rows ?? "*None*"} `.trim(); }, }, @@ -113,15 +53,14 @@ ${rows} heading: "data", render: (info) => { const rows = Object.entries(info.data) - .map(([name, param]) => renderDataRow(name, param)) + .map(([name, param]) => renderDataRow(name, param, getTypeName)) .join("\n"); - return ` -## Data + return `## Data In addition to the [default data collected by all plugins](https://www.jspsych.org/latest/overview/plugins#data-collected-by-all-plugins), this plugin collects the following data for each trial. ${topDataChart} -${rows} +${rows ?? "*None*"} `.trim(); }, }, @@ -138,8 +77,7 @@ ${example.code} \`\`\``, ) .join("\n\n"); - return ` -## Examples + return `## Examples ${sections} `.trim(); diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts index e69de29..5418560 100644 --- a/packages/autodoc/src/renderers/utils.ts +++ b/packages/autodoc/src/renderers/utils.ts @@ -0,0 +1,60 @@ +import { ParameterInfo } from "../types/info.js"; + +export const topParameterChart = `| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- |`; + +export const topDataChart = `| Name | Type | Value | +| ---- | ---- | ----- |`; + +const renderNestedParameterDescription = (nested: Record<string, ParameterInfo>): string => { + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested parameter "${name}" is missing a description.`); + param.description = "No description provided."; + } + const desc = param.nested + ? `${param.description} ${renderNestedParameterDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +}; + +export const renderParameterRow = (name: string, parameter: ParameterInfo, typeToString: (type: string, array?: boolean) => string): string => { + if (!parameter.description) { + console.warn(`Warning: Parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const defaultValue = !isNaN(parseFloat(parameter.default)) + ? parameter.default + : `\`${parameter.default}\``; + const description = parameter.nested + ? `${parameter.description} ${renderNestedParameterDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${typeToString(parameter.type, parameter.array)} | ${defaultValue} | ${description} |`; +}; + +const renderNestedDataDescription = (nested: Record<string, ParameterInfo>): string => { + const parts = Object.entries(nested).map(([name, param]) => { + if (!param.description) { + console.warn(`Warning: Nested data parameter "${name}" is missing a description.`); + param.description = "No description provided."; + } + const desc = param.nested + ? `${param.description} ${renderNestedDataDescription(param.nested)}` + : param.description; + return `\`${name}\`: ${desc}`; + }); + return `(${parts.join(", ")})`; +}; + +export const renderDataRow = (name: string, parameter: ParameterInfo, typeToString: (type: string, array?: boolean) => string): string => { + if (!parameter.description) { + console.warn(`Warning: Data parameter "${name}" is missing a description.`); + parameter.description = "No description provided."; + } + const value = parameter.nested + ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` + : parameter.description; + return `| ${name} | ${typeToString(parameter.type, parameter.array)} | ${value} |`; +}; \ No newline at end of file From 0e7acb6894f796b70313a82a6d02147fa96731eb Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:29:12 -0400 Subject: [PATCH 05/26] finish extension example parser and renderer, tests + refactor refactored same as last commit, put shared functions in the parser utils.ts --- packages/autodoc/src/parsers/extension.ts | 139 ++++++++++++++---- packages/autodoc/src/parsers/plugin.ts | 113 +------------- packages/autodoc/src/parsers/utils.ts | 136 ++++++++++++++++- packages/autodoc/src/renderers/extension.ts | 21 ++- .../examples/complex-inferred-example.html | 34 +++++ .../examples/complex-sentinel-example.html | 45 ++++++ .../extension/examples/ignored-example.html | 16 ++ .../examples/simple-inferred-example.html | 30 ++++ .../examples/simple-sentinel-example.html | 23 +++ .../autodoc/tests/parsers/extension.test.ts | 49 +++++- 10 files changed, 468 insertions(+), 138 deletions(-) create mode 100644 packages/autodoc/tests/fixtures/extension/examples/complex-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/extension/examples/complex-sentinel-example.html create mode 100644 packages/autodoc/tests/fixtures/extension/examples/ignored-example.html create mode 100644 packages/autodoc/tests/fixtures/extension/examples/simple-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/extension/examples/simple-sentinel-example.html diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 210ff40..4e286db 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -1,8 +1,6 @@ import ts from "typescript"; -import fs from "node:fs"; -import path from "node:path"; import { ExtensionInfo } from "../types/info.js"; -import { extractJsDocComment, parseParamGroup, parseTSParamGroup } from "./utils.js"; +import { collectExamples, dedent, extractJsDocComment, parseParamGroup, parseTSParamGroup } from "./utils.js"; export function getExtensionInfo( source: ts.SourceFile, @@ -74,36 +72,125 @@ export function getExtensionInfo( return result; } -export function getExtensionInfoAndExamples( - source: ts.SourceFile, - classNode: ts.ClassDeclaration, - examplePath: string, -): ExtensionInfo { - const info = getExtensionInfo(source, classNode); +/** + * Fallback code block extractor for HTML extension example files without sentinels. Requires + * exactly one inline script block. Extracts the `initJsPsych` call and all trial variables + * (camel/snake case) whose object literal contains an `extensions` field. For each matched + * trial, its direct local dependencies (one level of indirection) are also included. + * The initJsPsych variable itself is never treated as a dependency. + */ +function inferCodeBlock(sourceContent: string, sourcePath: string): string { + const scriptRegex = /<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; + const blocks: string[] = []; + let match: RegExpExecArray | null; + while ((match = scriptRegex.exec(sourceContent)) !== null) blocks.push(match[1]); + + if (blocks.length === 0) throw new Error(`${sourcePath}: no inline script blocks found`); + if (blocks.length > 1) + throw new Error( + `${sourcePath}: multiple inline script blocks found, use jspsych-autodoc:start/end sentinels instead`, + ); + + const scriptContent = blocks[0]; + const sourceFile = ts.createSourceFile("example.js", scriptContent, ts.ScriptTarget.Latest, true); - if (!fs.existsSync(examplePath)) { - throw new Error(`Example path does not exist: ${examplePath}`); + const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; + let initStatement: ts.VariableStatement | undefined; + let initJsPsychVarName: string | undefined; + const trialNodes: ts.VariableDeclaration[] = []; + + function visitNodes(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const init = node.initializer; + + if ( + init && + ts.isCallExpression(init) && + ts.isIdentifier(init.expression) && + init.expression.text === "initJsPsych" + ) { + const stmt = node.parent.parent; + if (ts.isVariableStatement(stmt)) { + initStatement = stmt; + initJsPsychVarName = node.name.text; + } + } + + if (trialPattern.test(node.name.text) && init && ts.isObjectLiteralExpression(init)) { + const hasExtensions = init.properties.some( + (p) => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === "extensions", + ); + if (hasExtensions) trialNodes.push(node); + } + } + ts.forEachChild(node, visitNodes); } + visitNodes(sourceFile); - const stat = fs.statSync(examplePath); - const htmlFiles: string[] = []; + if (!initStatement) + throw new Error( + `${sourcePath}: no initJsPsych call found — use jspsych-autodoc:start/end sentinels instead`, + ); - if (stat.isDirectory()) { - htmlFiles.push( - ...fs - .readdirSync(examplePath) - .filter((f) => f.endsWith(".html")) - .map((f) => path.join(examplePath, f)), + if (trialNodes.length === 0) + throw new Error( + `${sourcePath}: no trial variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, ); - } else if (stat.isFile()) { - if (!examplePath.endsWith(".html")) { - throw new Error(`Example file must be an HTML file: ${examplePath}`); + + // build map of all local variable declarations, excluding trial nodes themselves + const localDecls = new Map<string, ts.VariableStatement>(); + function visitDecls(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const stmt = node.parent.parent; + if (ts.isVariableStatement(stmt)) localDecls.set(node.name.text, stmt); } - htmlFiles.push(examplePath); - } else { - throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); + ts.forEachChild(node, visitDecls); } + visitDecls(sourceFile); + if (initJsPsychVarName) localDecls.delete(initJsPsychVarName); + for (const trial of trialNodes) localDecls.delete((trial.name as ts.Identifier).text); - // TODO: actually implement this + function collectIdentifiers(node: ts.Node, result: Set<string>) { + if (ts.isPropertyAssignment(node)) { + collectIdentifiers(node.initializer, result); + return; + } + if (ts.isIdentifier(node)) { + result.add(node.text); + return; + } + ts.forEachChild(node, (child) => collectIdentifiers(child, result)); + } + + const outputStatements = new Map<string, ts.Node>(); + outputStatements.set("__initJsPsych__", initStatement); + + for (const trial of trialNodes) { + const trialStmt = trial.parent.parent; + if (ts.isVariableStatement(trialStmt)) + outputStatements.set((trial.name as ts.Identifier).text, trialStmt); + + const refs = new Set<string>(); + collectIdentifiers(trial.initializer!, refs); + for (const ref of refs) + if (localDecls.has(ref)) outputStatements.set(ref, localDecls.get(ref)!); + } + + return Array.from(outputStatements.values()) + .sort((a, b) => a.pos - b.pos) + .map((node) => dedent(node.getFullText(sourceFile))) + .join("\n\n"); +} + +export function getExtensionInfoAndExamples( + source: ts.SourceFile, + classNode: ts.ClassDeclaration, + examplePath: string, +): ExtensionInfo { + const info = getExtensionInfo(source, classNode); + info.examples = collectExamples(examplePath, inferCodeBlock); return info; } diff --git a/packages/autodoc/src/parsers/plugin.ts b/packages/autodoc/src/parsers/plugin.ts index ee60212..0d5fd17 100644 --- a/packages/autodoc/src/parsers/plugin.ts +++ b/packages/autodoc/src/parsers/plugin.ts @@ -1,8 +1,6 @@ -import fs from "node:fs"; -import path from "node:path"; import ts from "typescript"; -import { PluginInfo, ParameterInfo, ExampleInfo } from "../types/info.js"; -import { extractJsDocComment, parseParamGroup } from "./utils.js"; +import { PluginInfo } from "../types/info.js"; +import { collectExamples, dedent, extractJsDocComment, parseParamGroup } from "./utils.js"; /** * Extracts plugin information from a TypeScript AST. Source must already be @@ -156,85 +154,10 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { return Array.from(outputStatements.values()) .sort((a, b) => a.pos - b.pos) - .map((node) => node.getText(sourceFile).trim()) + .map((node) => dedent(node.getFullText(sourceFile))) .join("\n\n"); } -/** - * Gets the example code block text from a given HTML file. Looks for sentinels first and - * orders sub-blocks via file position. Otherwise, infers based on trial variable declarations - * and their dependencies. - */ -function getCodeBlock(sourceContent: string, sourcePath: string): string { - const START = "// jspsych-autodoc:start"; - const END = "// jspsych-autodoc:end"; - - type Marker = { type: "start" | "end"; pos: number }; - const markers: Marker[] = []; - - let i = 0; - while (i < sourceContent.length) { - const s = sourceContent.indexOf(START, i); - const e = sourceContent.indexOf(END, i); - if (s === -1 && e === -1) break; - if (s !== -1 && (e === -1 || s < e)) { - markers.push({ type: "start", pos: s }); - i = s + START.length; - } else { - markers.push({ type: "end", pos: e }); - i = e + END.length; - } - } - - if (markers.length === 0) return inferCodeBlock(sourceContent, sourcePath); - - for (let j = 0; j < markers.length; j++) { - const expected = j % 2 === 0 ? "start" : "end"; - if (markers[j].type !== expected) - throw new Error( - `${sourcePath}: mismatched jspsych-autodoc sentinels: unexpected ${markers[j].type} at marker ${j + 1}`, - ); - } - if (markers.length % 2 !== 0) - throw new Error( - `${sourcePath}: mismatched jspsych-autodoc sentinels: last start has no matching end`, - ); - - const blocks: string[] = []; - for (let j = 0; j < markers.length; j += 2) { - const newlineAfterStart = sourceContent.indexOf("\n", markers[j].pos); - const blockStart = - newlineAfterStart === -1 ? markers[j].pos + START.length : newlineAfterStart + 1; - const blockEnd = markers[j + 1].pos; - blocks.push(sourceContent.slice(blockStart, blockEnd).trimEnd()); - } - - return blocks.join("\n\n"); -} - -/** Fetch example information from a given HTML filepath. `undefined` if the file is ignored - * via sentinel <!-- jspsych-autodoc:ignore -->. */ -function getExampleInfo(sourcePath: string): Record<string, ExampleInfo> | undefined { - const content = fs.readFileSync(sourcePath, "utf-8"); - - if (/<!--\s*jspsych-autodoc:ignore\s*-->/.test(content)) return undefined; - - let title: string; - - const sentinelMatch = content.match(/<!--\s*jspsych-autodoc:title\s+(.+?)\s*-->/); - if (sentinelMatch) { - title = sentinelMatch[1].trim(); - } else { - const titleTagMatch = content.match(/<title>([\s\S]*?)<\/title>/i); - if (!titleTagMatch) throw new Error(`No title found in example file: ${sourcePath}`); - title = titleTagMatch[1].trim(); - } - - return { - [title]: { path: sourcePath, code: getCodeBlock(content, sourcePath) }, - }; -} - /** * Extracts plugin information from a TypeScript AST. Source must already be * transformed via the TypeScript compiler. Also gathers example information @@ -251,34 +174,6 @@ export function getPluginInfoAndExamples( examplePath: string, ): PluginInfo { const info = getPluginInfo(source, classNode); - - if (!fs.existsSync(examplePath)) { - throw new Error(`Example path does not exist: ${examplePath}`); - } - - const stat = fs.statSync(examplePath); - const htmlFiles: string[] = []; - - if (stat.isDirectory()) { - htmlFiles.push( - ...fs - .readdirSync(examplePath) - .filter((f) => f.endsWith(".html")) - .map((f) => path.join(examplePath, f)), - ); - } else if (stat.isFile()) { - if (!examplePath.endsWith(".html")) { - throw new Error(`Example file must be an HTML file: ${examplePath}`); - } - htmlFiles.push(examplePath); - } else { - throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); - } - - for (const file of htmlFiles) { - const exampleInfo = getExampleInfo(file); - if (exampleInfo) Object.assign(info.examples, exampleInfo); - } - + info.examples = collectExamples(examplePath, inferCodeBlock); return info; } diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index c129c3f..16c855e 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -1,5 +1,9 @@ +import fs from "node:fs"; +import path from "node:path"; import ts from "typescript"; -import { ParameterInfo } from "../types/info.js"; +import { ExampleInfo, ParameterInfo } from "../types/info.js"; + +// --- PARSE SRC FILE UTILS --- /** thing that removes comments from nested parameter types */ const printer = ts.createPrinter({ removeComments: true }); @@ -145,4 +149,134 @@ function parseTSProperty( } return info as ParameterInfo; +} + +// --- PARSE EXAMPLE FILE UTILS --- + +/** Strips common leading whitespace from all lines of a multi-line string. */ +export function dedent(text: string): string { + const lines = text.split("\n"); + const minIndent = Math.min( + ...lines.filter((l) => l.trim().length > 0).map((l) => l.match(/^( *)/)![1].length), + ); + return lines.map((l) => l.slice(minIndent)).join("\n").trim(); +} + +/** + * Gets the example code block text from a given HTML file. Looks for sentinels first and + * orders sub-blocks via file position. Otherwise, delegates to the provided fallback or throws. + */ +export function getCodeBlock( + sourceContent: string, + sourcePath: string, + inferFallback: (content: string, path: string) => string, +): string { + const START = "// jspsych-autodoc:start"; + const END = "// jspsych-autodoc:end"; + + type Marker = { type: "start" | "end"; pos: number }; + const markers: Marker[] = []; + + let i = 0; + while (i < sourceContent.length) { + const s = sourceContent.indexOf(START, i); + const e = sourceContent.indexOf(END, i); + if (s === -1 && e === -1) break; + if (s !== -1 && (e === -1 || s < e)) { + markers.push({ type: "start", pos: s }); + i = s + START.length; + } else { + markers.push({ type: "end", pos: e }); + i = e + END.length; + } + } + + if (markers.length === 0) { + return inferFallback(sourceContent, sourcePath); + } + + for (let j = 0; j < markers.length; j++) { + const expected = j % 2 === 0 ? "start" : "end"; + if (markers[j].type !== expected) + throw new Error( + `${sourcePath}: mismatched jspsych-autodoc sentinels: unexpected ${markers[j].type} at marker ${j + 1}`, + ); + } + if (markers.length % 2 !== 0) + throw new Error( + `${sourcePath}: mismatched jspsych-autodoc sentinels: last start has no matching end`, + ); + + const blocks: string[] = []; + for (let j = 0; j < markers.length; j += 2) { + const newlineAfterStart = sourceContent.indexOf("\n", markers[j].pos); + const blockStart = + newlineAfterStart === -1 ? markers[j].pos + START.length : newlineAfterStart + 1; + const blockEnd = markers[j + 1].pos; + blocks.push(sourceContent.slice(blockStart, blockEnd).trimEnd()); + } + + return blocks.join("\n\n"); +} + +/** Fetch example information from a given HTML filepath. `undefined` if the file is ignored + * via sentinel <!-- jspsych-autodoc:ignore -->. */ +export function getExampleInfo( + sourcePath: string, + inferFallback: (content: string, path: string) => string, +): Record<string, ExampleInfo> | undefined { + const content = fs.readFileSync(sourcePath, "utf-8"); + + if (/<!--\s*jspsych-autodoc:ignore\s*-->/.test(content)) return undefined; + + let title: string; + + const sentinelMatch = content.match(/<!--\s*jspsych-autodoc:title\s+(.+?)\s*-->/); + if (sentinelMatch) { + title = sentinelMatch[1].trim(); + } else { + const titleTagMatch = content.match(/<title>([\s\S]*?)<\/title>/i); + if (!titleTagMatch) throw new Error(`No title found in example file: ${sourcePath}`); + title = titleTagMatch[1].trim(); + } + + return { + [title]: { path: sourcePath, code: getCodeBlock(content, sourcePath, inferFallback) }, + }; +} + +/** Collects example info from a directory or single HTML file. */ +export function collectExamples( + examplePath: string, + inferFallback: (content: string, path: string) => string, +): Record<string, ExampleInfo> { + if (!fs.existsSync(examplePath)) { + throw new Error(`Example path does not exist: ${examplePath}`); + } + + const stat = fs.statSync(examplePath); + const htmlFiles: string[] = []; + + if (stat.isDirectory()) { + htmlFiles.push( + ...fs + .readdirSync(examplePath) + .filter((f) => f.endsWith(".html")) + .map((f) => path.join(examplePath, f)), + ); + } else if (stat.isFile()) { + if (!examplePath.endsWith(".html")) { + throw new Error(`Example file must be an HTML file: ${examplePath}`); + } + htmlFiles.push(examplePath); + } else { + throw new Error(`Example path is neither a file nor a directory: ${examplePath}`); + } + + const result: Record<string, ExampleInfo> = {}; + for (const file of htmlFiles) { + const exampleInfo = getExampleInfo(file, inferFallback); + if (exampleInfo) Object.assign(result, exampleInfo); + } + return result; } \ No newline at end of file diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index d9d500a..fbe639f 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -80,7 +80,26 @@ ${topDataChart} ${rows ?? "*None*"} `.trim(); }, - } + }, + { + heading: "examples", + render: (info) => { + const sections = Object.entries(info.examples) + .map( + ([title, example]) => + `### ${title} (${example.path}) + +\`\`\`js +${example.code} +\`\`\``, + ) + .join("\n\n"); + return `## Examples + +${sections} +`.trim(); + }, + }, ] export function getExtensionDocs(info: ExtensionInfo): Record<string, string> { diff --git a/packages/autodoc/tests/fixtures/extension/examples/complex-inferred-example.html b/packages/autodoc/tests/fixtures/extension/examples/complex-inferred-example.html new file mode 100644 index 0000000..ac67756 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/examples/complex-inferred-example.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> +<head> + <title>complex inferred example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/examples/complex-sentinel-example.html b/packages/autodoc/tests/fixtures/extension/examples/complex-sentinel-example.html new file mode 100644 index 0000000..4c84409 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/examples/complex-sentinel-example.html @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/examples/ignored-example.html b/packages/autodoc/tests/fixtures/extension/examples/ignored-example.html new file mode 100644 index 0000000..37e52e0 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/examples/ignored-example.html @@ -0,0 +1,16 @@ + + + + + ignored example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/examples/simple-inferred-example.html b/packages/autodoc/tests/fixtures/extension/examples/simple-inferred-example.html new file mode 100644 index 0000000..68a899a --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/examples/simple-inferred-example.html @@ -0,0 +1,30 @@ + + + + simple inferred example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/examples/simple-sentinel-example.html b/packages/autodoc/tests/fixtures/extension/examples/simple-sentinel-example.html new file mode 100644 index 0000000..51670e3 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/examples/simple-sentinel-example.html @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 643b4bb..503a9d8 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -1,4 +1,4 @@ -import { getExtensionInfo } from '../../src/parsers/extension.js'; +import { getExtensionInfo, getExtensionInfoAndExamples } from '../../src/parsers/extension.js'; import ts from 'typescript'; import fs from 'node:fs'; import path from 'node:path'; @@ -92,3 +92,50 @@ describe('getExtensionInfo', () => { expect(info.data.grid.nested).toBeDefined(); }); }); + +describe('getExtensionInfoAndExamples', () => { + const examplesDir = path.resolve(__dirname, '../fixtures/extension/examples'); + + it('should extract examples from a provided file', () => { + const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['simple sentinel example']).toBeDefined(); + expect(info.examples['simple sentinel example'].path).toBe(filePath); + expect(info.examples['simple sentinel example'].code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};' + ); + }); + + it('should extract examples from a provided directory', () => { + const { classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, examplesDir); + expect(Object.keys(info.examples)).toHaveLength(4); + expect(info.examples['ignored example']).toBeUndefined(); + + const simpleSentinelExample = info.examples['simple sentinel example']; + expect(simpleSentinelExample.path).toBe(path.join(examplesDir, 'simple-sentinel-example.html')); + expect(simpleSentinelExample.code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};' + ); + + const complexSentinelExample = info.examples['complex sentinel example']; + expect(complexSentinelExample.path).toBe(path.join(examplesDir, 'complex-sentinel-example.html')); + expect(complexSentinelExample.code).toBe( + 'var jsPsych = initJsPsych({\n extensions: [\n {type: jsPsychTestExtension}\n ]\n});\n\nvar helloTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};\n\nvar goodbyeTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Goodbye",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "bye"}}\n ]\n};' + ); + + const simpleInferredExample = info.examples['simple inferred example']; + expect(simpleInferredExample.path).toBe(path.join(examplesDir, 'simple-inferred-example.html')); + expect(simpleInferredExample.code).toBe( + 'var jsPsych = initJsPsych({\n extensions: [\n {type: jsPsychTestExtension, params: {test: "inferred"}}\n ]\n});\n\nvar trial = {\n type: jsPsychTestPlugin,\n stimulus: "World",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "trial-level inferred"}}\n ]\n};' + ); + + const complexInferredExample = info.examples['complex inferred example']; + expect(complexInferredExample.path).toBe(path.join(examplesDir, 'complex-inferred-example.html')); + expect(complexInferredExample.code).toBe( + 'var jsPsych = initJsPsych();\n\nvar stimulus = "Hello, world!";\n\nvar duration = 1000;\n\nvar choices = ["f", "j"];\n\nvar trial = {\n type: jsPsychTestPlugin,\n stimulus: stimulus,\n trial_duration: duration,\n choices: choices,\n extensions: [\n {type: jsPsychTestExtension, params: {test: "inferred complex"}}\n ]\n};' + ); + }); +}); From 1f7f9076e443ff4bf97ba266ac4e3d7a4addcf3b Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:35:54 -0400 Subject: [PATCH 06/26] add auto-detection of package type, set up timeline skeleton --- packages/autodoc/src/cli.ts | 27 +++++++++--- packages/autodoc/src/parsers/timeline.ts | 37 +++++++++++++--- packages/autodoc/src/renderers/timeline.ts | 18 ++++++++ packages/autodoc/src/types/info.ts | 18 ++++++++ packages/autodoc/src/utils.ts | 33 +++++++++----- packages/autodoc/tests/cli.test.ts | 11 ++++- .../autodoc/tests/fixtures/timeline/basic.ts | 27 ++++++++++++ .../autodoc/tests/parsers/extension.test.ts | 44 +++++++++---------- packages/autodoc/tests/parsers/plugin.test.ts | 28 ++++++------ .../autodoc/tests/parsers/timeline.test.ts | 3 ++ packages/autodoc/tests/utils.ts | 4 ++ 11 files changed, 187 insertions(+), 63 deletions(-) create mode 100644 packages/autodoc/src/renderers/timeline.ts create mode 100644 packages/autodoc/tests/parsers/timeline.test.ts diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 583e0e0..3bf09d1 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -10,9 +10,11 @@ import { Command } from "commander"; import { extractVersionFromPackageJson, identifyPackageType, updateDocSections } from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; import { getPluginDocs } from "./renderers/plugin.js"; -import { ExtensionInfo, PluginInfo } from "./types/info.js"; import { getExtensionInfo, getExtensionInfoAndExamples } from "./parsers/extension.js"; import { getExtensionDocs } from "./renderers/extension.js"; +import { getTimelineInfo, getTimelineInfoAndExamples } from "./parsers/timeline.js"; +import { getTimelineDocs } from "./renderers/timeline.js"; +import { ExtensionInfo, PluginInfo, TimelineInfo } from "./types/info.js"; // auto get version from package.json const __filename = fileURLToPath(import.meta.url); @@ -49,7 +51,7 @@ function main(options: CliOptions): void { true, ); - const { classNode, type } = identifyPackageType(source); + const { mainNode, type } = identifyPackageType(source); console.log(type) let docs: Record; @@ -58,9 +60,9 @@ function main(options: CliOptions): void { let extensionInfo: ExtensionInfo; if (options.example) { - extensionInfo = getExtensionInfoAndExamples(source, classNode, options.example); + extensionInfo = getExtensionInfoAndExamples(source, mainNode as ts.ClassDeclaration, options.example); } else { - extensionInfo = getExtensionInfo(source, classNode); + extensionInfo = getExtensionInfo(source, mainNode as ts.ClassDeclaration); } extensionInfo.version = extractVersionFromPackageJson(); @@ -71,14 +73,27 @@ function main(options: CliOptions): void { let pluginInfo: PluginInfo; if (options.example) { - pluginInfo = getPluginInfoAndExamples(source, classNode, options.example); + pluginInfo = getPluginInfoAndExamples(source, mainNode as ts.ClassDeclaration, options.example); } else { - pluginInfo = getPluginInfo(source, classNode); + pluginInfo = getPluginInfo(source, mainNode as ts.ClassDeclaration); } pluginInfo.version = extractVersionFromPackageJson(); docs = getPluginDocs(pluginInfo); + } else if (type === "timeline") { + console.log("Identified package type: timeline"); + + let timelineInfo: TimelineInfo; + if (options.example) { + timelineInfo = getTimelineInfoAndExamples(source, mainNode as ts.FunctionDeclaration, options.example); + } else { + timelineInfo = getTimelineInfo(source, mainNode as ts.FunctionDeclaration); + } + + timelineInfo.version = extractVersionFromPackageJson(); + + docs = getTimelineDocs(timelineInfo); } else { throw new Error("Unrecognized package type."); } diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts index 400e665..803eb8b 100644 --- a/packages/autodoc/src/parsers/timeline.ts +++ b/packages/autodoc/src/parsers/timeline.ts @@ -1,8 +1,31 @@ -export function getTimelineInfo(info: any): string[] { - // Placeholder implementation, replace with actual logic to retrieve documentation - return [ - `Documentation for timeline: ${info.name}`, - `Description: ${info.description}`, - `Version: ${info.version}`, - ]; +import ts from "typescript"; +import { TimelineInfo, TimelineHelperInfo } from "../types/info.js"; +import { collectExamples, dedent, extractJsDocComment, parseParamGroup } from "./utils.js"; + +export function getTimelineInfo(sourceFile: ts.SourceFile, createTimelineNode: ts.FunctionDeclaration): TimelineInfo { + let result: TimelineInfo = { + name: "", + description: "", + version: "", + createTimeline: { description: "", helperParameters: {} }, + timelineUnits: {}, + utils: {}, + examples: {}, + }; + + return result; +} + +function inferCodeBlock(content: string, path: string): string { + return ""; +} + +export function getTimelineInfoAndExamples( + sourceFile: ts.SourceFile, + createTimelineNode: ts.FunctionDeclaration, + examplePath: string, +): TimelineInfo { + const info = getTimelineInfo(sourceFile, createTimelineNode); + info.examples = collectExamples(examplePath, inferCodeBlock); + return info; } diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts new file mode 100644 index 0000000..7d2f9da --- /dev/null +++ b/packages/autodoc/src/renderers/timeline.ts @@ -0,0 +1,18 @@ +import { SectionTemplate, TimelineInfo } from "../types/info.js"; + +const mainTemplate: SectionTemplate[] = [ + { + heading: "", + render: (info) => "" + } +] + +export function getTimelineDocs(info: TimelineInfo): Record { + return Object.fromEntries( + mainTemplate.map((section) => { + const content = section.render(info); + const wrapped = `\n${content}\n`; + return [section.heading, wrapped]; + }), + ); +} \ No newline at end of file diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts index a76d32b..1f884a1 100644 --- a/packages/autodoc/src/types/info.ts +++ b/packages/autodoc/src/types/info.ts @@ -19,6 +19,23 @@ export interface ExtensionInfo { examples: Record; } +export interface TimelineInfo { + name: string; + description: string; // TODO: just gather this from package.json + version: string; + createTimeline: TimelineHelperInfo; // only one: no need to attach name + timelineUnits: Record; + utils: Record; + examples: Record; +} + +// name is attached via Record +export interface TimelineHelperInfo { + description: string; + helperParameters: Record; +} + +// name is attached via Record export interface ParameterInfo { type: string; default: string; @@ -27,6 +44,7 @@ export interface ParameterInfo { nested?: Record; } +// name is attached via Record export interface ExampleInfo { path: string; code: string; diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts index 5e870d1..37606b9 100644 --- a/packages/autodoc/src/utils.ts +++ b/packages/autodoc/src/utils.ts @@ -74,19 +74,25 @@ export function updateDocSections(fileContent: string, docs: Record @@ -102,17 +108,20 @@ export function identifyPackageType(source: ts.SourceFile): { throw new Error( "A class cannot implement both JsPsychPlugin and JsPsychExtension interfaces.", ); - } - else if (implementsExtension) result = { classNode: node, type: "extension" }; - else if (implementsPlugin) result = { classNode: node, type: "plugin" }; + } + else if (implementsExtension) result = { mainNode: node, type: "extension" }; + else if (implementsPlugin) result = { mainNode: node, type: "plugin" }; else throw new Error( "Class does not implement JsPsychPlugin or JsPsychExtension interfaces. Ensure your class implements the correct interface.", ); + } else if (ts.isFunctionDeclaration(node)) { + const isCreateTimeline = node.name?.text === "createTimeline"; + if (isCreateTimeline) result = { mainNode: node as ts.FunctionDeclaration, type: "timeline" }; } - ts.forEachChild(node, visitClass); + ts.forEachChild(node, searchForMainNode); } - visitClass(source); + searchForMainNode(source); if (!result) { throw new Error("No plugin or extension class found in source file."); diff --git a/packages/autodoc/tests/cli.test.ts b/packages/autodoc/tests/cli.test.ts index 779aba3..6117ad3 100644 --- a/packages/autodoc/tests/cli.test.ts +++ b/packages/autodoc/tests/cli.test.ts @@ -18,6 +18,7 @@ function loadFixture(relativePath: string) { const pluginSource = loadFixture("fixtures/plugin/basic.ts"); const extensionSource = loadFixture("fixtures/extension/basic.ts"); +const timelineSource = loadFixture("fixtures/timeline/basic.ts") const bothInterfacesSource = loadFixture("fixtures/utils/both-interfaces.ts"); const noClassSource = loadFixture("fixtures/utils/no-class.ts"); @@ -25,15 +26,21 @@ describe("identifyPackageType", () => { it("identifies plugin class", () => { const result = identifyPackageType(pluginSource); expect(result.type).toBe("plugin"); - expect(result.classNode.name?.text).toBe("TestPlugin"); + expect(result.mainNode.name?.text).toBe("TestPlugin"); }); it("identifies extension class", () => { const result = identifyPackageType(extensionSource); expect(result.type).toBe("extension"); - expect(result.classNode.name?.text).toBe("TestExtension"); + expect(result.mainNode.name?.text).toBe("TestExtension"); }); + it("identifies timeline function", () => { + const result = identifyPackageType(timelineSource); + expect(result.type).toBe("timeline") + expect(result.mainNode.name?.text).toBe("createTimeline"); + }) + it("throws if class implements both interfaces", () => { expect(() => identifyPackageType(bothInterfacesSource)).toThrow( "A class cannot implement both JsPsychPlugin and JsPsychExtension interfaces." diff --git a/packages/autodoc/tests/fixtures/timeline/basic.ts b/packages/autodoc/tests/fixtures/timeline/basic.ts index e69de29..61a0c71 100644 --- a/packages/autodoc/tests/fixtures/timeline/basic.ts +++ b/packages/autodoc/tests/fixtures/timeline/basic.ts @@ -0,0 +1,27 @@ +import { JsPsych } from "../../utils.js"; + + +function createIntroduction({}) { + +} + +function createDoohickey({}) { + +} + +function doohickeyHelper({}) { + +} + +export function createTimeline(jsPsych: JsPsych, {}) { + +} + +export const timelineUnits = { + createIntroduction, + createDoohickey, +} + +export const utils = { + doohickeyHelper, +} diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 503a9d8..de0ea77 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -16,20 +16,20 @@ const fixtureSource = ts.createSourceFile( describe('getExtensionInfo', () => { it('extracts name', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.name).toBe('test-extension'); }); it('extracts class JSDoc as description', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.description).toBe('A test jsPsych extension.'); }); it('extracts initializeParameters with types, descriptions, and defaults', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.initializeParameters.single.type).toBe('number'); expect(info.initializeParameters.single.description).toBe('Single-line description.'); expect(info.initializeParameters.single.default).toBe('0'); @@ -39,24 +39,24 @@ describe('getExtensionInfo', () => { }); it('extracts array flag on initializeParameters', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.initializeParameters.list_of_stimuli.array).toBe(true); expect(info.initializeParameters.list_of_stimuli.type).toBe('string'); expect(info.initializeParameters.list_of_stimuli.default).toBe('["stim1.png", "stim2.png"]'); }); it('extracts onStartParameters with object type', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.onStartParameters.nested_object.type).toBe('{ nested_param: number }'); expect(info.onStartParameters.nested_object.description).toBe("Let's try an object."); expect(info.onStartParameters.nested_object.default).toBe('{ nested_param: 42 }'); }); it('extracts onLoadParameters with nested descriptions and defaults', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.onLoadParameters.grid.description).toBe('Maybe a grid.'); expect(info.onLoadParameters.grid.default).toBeUndefined(); expect(info.onLoadParameters.grid.nested).toBeDefined(); @@ -67,8 +67,8 @@ describe('getExtensionInfo', () => { }); it('extracts onFinishParameters with nested array type', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.onFinishParameters.boolean_param.array).toBe(true); expect(info.onFinishParameters.boolean_param.type).toBe('boolean[]'); expect(info.onFinishParameters.boolean_param.description).toBe('And a boolean grid.'); @@ -76,8 +76,8 @@ describe('getExtensionInfo', () => { }); it('extracts data parameters', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); expect(info.data.data_param.description).toBe('Data parameter description.'); expect(info.data.double_data.type).toBe('ParameterType.BOOL'); @@ -85,8 +85,8 @@ describe('getExtensionInfo', () => { }); it('extracts nested data parameters', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.data.grid.type).toBe('ParameterType.COMPLEX'); expect(info.data.grid.description).toBe("Now let's have a grid."); expect(info.data.grid.nested).toBeDefined(); @@ -98,8 +98,8 @@ describe('getExtensionInfoAndExamples', () => { it('should extract examples from a provided file', () => { const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, filePath); expect(Object.keys(info.examples)).toHaveLength(1); expect(info.examples['simple sentinel example']).toBeDefined(); expect(info.examples['simple sentinel example'].path).toBe(filePath); @@ -109,8 +109,8 @@ describe('getExtensionInfoAndExamples', () => { }); it('should extract examples from a provided directory', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getExtensionInfoAndExamples(fixtureSource, classNode, examplesDir); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, examplesDir); expect(Object.keys(info.examples)).toHaveLength(4); expect(info.examples['ignored example']).toBeUndefined(); diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index 9723fa4..f0d1007 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -16,20 +16,20 @@ const fixtureSource = ts.createSourceFile( describe('getPluginInfo', () => { it('extracts name', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.name).toBe('test-plugin'); }); it('extracts class JSDoc as description', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.description).toBe('A test jsPsych plugin.'); }); it('extracts parameters with types and descriptions', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.parameters.single.type).toBe('ParameterType.STRING'); expect(info.parameters.single.description).toBe('Single-line description.'); expect(info.parameters.double_double.type).toBe('ParameterType.INT'); @@ -37,14 +37,14 @@ describe('getPluginInfo', () => { }); it('extracts array flag on parameters', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.parameters.list_of_stimuli.array).toBe(true); }); it('extracts data parameters', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfo(fixtureSource, classNode); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); expect(info.data.data_param.description).toBe('Data parameter description.'); expect(info.data.double_data.type).toBe('ParameterType.BOOL'); @@ -57,8 +57,8 @@ describe('getPluginInfoAndExamples', () => { it('should extract examples from provided file', () => { const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfoAndExamples(fixtureSource, classNode, filePath); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, filePath); expect(Object.keys(info.examples)).toHaveLength(1); expect(info.examples['simple sentinel example']).toBeDefined(); expect(info.examples['simple sentinel example'].path).toBe(filePath); @@ -68,8 +68,8 @@ describe('getPluginInfoAndExamples', () => { }); it('should extract examples from provided directory', () => { - const { classNode } = identifyPackageType(fixtureSource); - const info = getPluginInfoAndExamples(fixtureSource, classNode, examplesDir); + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, examplesDir); expect(Object.keys(info.examples)).toHaveLength(4); expect(info.examples['ignored example']).toBeUndefined(); diff --git a/packages/autodoc/tests/parsers/timeline.test.ts b/packages/autodoc/tests/parsers/timeline.test.ts new file mode 100644 index 0000000..d46f7ac --- /dev/null +++ b/packages/autodoc/tests/parsers/timeline.test.ts @@ -0,0 +1,3 @@ +describe('timeline parser', () => { + test.todo('parses timeline info'); +}); diff --git a/packages/autodoc/tests/utils.ts b/packages/autodoc/tests/utils.ts index 581ac30..d54221d 100644 --- a/packages/autodoc/tests/utils.ts +++ b/packages/autodoc/tests/utils.ts @@ -9,3 +9,7 @@ export interface JsPsychPlugin { export interface JsPsychExtension { } + +export type JsPsych = { + dummy: string; +} From 8ddec93cd7e9040c64e8a2b27100f2424b33c618 Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:44:58 -0400 Subject: [PATCH 07/26] add timeline parsing and rendering and testing --- packages/autodoc/src/cli.ts | 20 +- packages/autodoc/src/parsers/timeline.ts | 530 +++++++++++++++++- packages/autodoc/src/parsers/utils.ts | 3 +- packages/autodoc/src/renderers/timeline.ts | 145 ++++- packages/autodoc/src/types/info.ts | 26 +- packages/autodoc/src/utils.ts | 39 +- .../autodoc/tests/fixtures/timeline/basic.ts | 69 ++- .../examples/complex-inferred-example.html | 37 ++ .../examples/complex-sentinel-example.html | 35 ++ .../timeline/examples/ignored-example.html | 14 + .../examples/simple-inferred-example.html | 19 + .../examples/simple-sentinel-example.html | 19 + .../autodoc/tests/parsers/timeline.test.ts | 114 +++- 13 files changed, 1012 insertions(+), 58 deletions(-) create mode 100644 packages/autodoc/tests/fixtures/timeline/examples/complex-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/timeline/examples/complex-sentinel-example.html create mode 100644 packages/autodoc/tests/fixtures/timeline/examples/ignored-example.html create mode 100644 packages/autodoc/tests/fixtures/timeline/examples/simple-inferred-example.html create mode 100644 packages/autodoc/tests/fixtures/timeline/examples/simple-sentinel-example.html diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 3bf09d1..bfe029b 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -7,7 +7,7 @@ import ts from "typescript"; import { Command } from "commander"; -import { extractVersionFromPackageJson, identifyPackageType, updateDocSections } from "./utils.js"; +import { extractPackageJsonInfo, identifyPackageType, updateDocSections } from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; import { getPluginDocs } from "./renderers/plugin.js"; import { getExtensionInfo, getExtensionInfoAndExamples } from "./parsers/extension.js"; @@ -29,6 +29,7 @@ interface CliOptions { source?: string; dest?: string; example?: string; + packageJson?: string; } // TODO: simulation mode-- detect if simulation mode is supported via these plugins. @@ -65,7 +66,7 @@ function main(options: CliOptions): void { extensionInfo = getExtensionInfo(source, mainNode as ts.ClassDeclaration); } - extensionInfo.version = extractVersionFromPackageJson(); + extensionInfo.version = extractPackageJsonInfo(options.packageJson).version; docs = getExtensionDocs(extensionInfo); } else if (type === "plugin") { @@ -78,7 +79,7 @@ function main(options: CliOptions): void { pluginInfo = getPluginInfo(source, mainNode as ts.ClassDeclaration); } - pluginInfo.version = extractVersionFromPackageJson(); + pluginInfo.version = extractPackageJsonInfo(options.packageJson).version; docs = getPluginDocs(pluginInfo); } else if (type === "timeline") { @@ -86,12 +87,15 @@ function main(options: CliOptions): void { let timelineInfo: TimelineInfo; if (options.example) { - timelineInfo = getTimelineInfoAndExamples(source, mainNode as ts.FunctionDeclaration, options.example); + timelineInfo = getTimelineInfoAndExamples(options.source, options.example); } else { - timelineInfo = getTimelineInfo(source, mainNode as ts.FunctionDeclaration); + timelineInfo = getTimelineInfo(options.source); } - timelineInfo.version = extractVersionFromPackageJson(); + const packageJsonInfo = extractPackageJsonInfo(options.packageJson); + timelineInfo.name = packageJsonInfo.name; + timelineInfo.description = packageJsonInfo.description; + timelineInfo.version = packageJsonInfo.version; docs = getTimelineDocs(timelineInfo); } else { @@ -122,6 +126,10 @@ program .option("--dest ", "Destination directory for the generated documentation") .option("--repo ", "Repository that contains the source/destination files (optional)") .option("--example ", "Example folder containing usages of the plugin (optional)") + .option( + "--package-json ", + "Path to the package.json to read name/description/version from (optional, defaults to ./package.json)", + ) .option("-v, --verbose", "Enable verbose logging (optional)") .option( "-f, --force", diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts index 803eb8b..3b470ea 100644 --- a/packages/autodoc/src/parsers/timeline.ts +++ b/packages/autodoc/src/parsers/timeline.ts @@ -1,31 +1,535 @@ import ts from "typescript"; -import { TimelineInfo, TimelineHelperInfo } from "../types/info.js"; -import { collectExamples, dedent, extractJsDocComment, parseParamGroup } from "./utils.js"; +import { TimelineInfo, TimelineHelperInfo, TimelineInterfaceInfo, ParameterInfo } from "../types/info.js"; +import { collectExamples, dedent, extractJsDocComment, printer } from "./utils.js"; -export function getTimelineInfo(sourceFile: ts.SourceFile, createTimelineNode: ts.FunctionDeclaration): TimelineInfo { - let result: TimelineInfo = { + +// --- INTERFACE MAP --- + +/** pairs together interface declarations with the corresponding + * source file it was found in */ +type InterfaceEntry = { decl: ts.InterfaceDeclaration; source: ts.SourceFile }; + +/** + * pairs together names of interface with their corresponding `ParameterInfo`s. + * makes it so we can hoist interfaces in one pass + */ +type UsageMap = Map; + +function recordInterfaceUsage( + usageMap: UsageMap, + interfaceMap: Map, + info: ParameterInfo, +): void { + if (!info.nested || !interfaceMap.has(info.type)) return; + const sites = usageMap.get(info.type); + if (sites) sites.push(info); + else usageMap.set(info.type, [info]); +} + +function buildInterfaceMap( + source: ts.SourceFile, + program?: ts.Program, +): Map { + const map = new Map(); + const filesToSearch = program + ? [ + source, + ...program + .getSourceFiles() + .filter((f) => f !== source && !f.fileName.includes("/node_modules/")), + ] + : [source]; + for (const file of filesToSearch) { + for (const stmt of file.statements) { + if (ts.isInterfaceDeclaration(stmt) && !map.has(stmt.name.text)) { + map.set(stmt.name.text, { decl: stmt, source: file }); + } + } + } + return map; +} + +// --- DEFAULT RESOLUTION --- + +function resolveDefaultExpr( + initializer: ts.Expression, + source: ts.SourceFile, +): ts.Expression { + if (ts.isIdentifier(initializer)) { + for (const stmt of source.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === initializer.text && + decl.initializer + ) { + return decl.initializer; + } + } + } + } + return initializer; +} + +function getPropertyExpression( + objLiteral: ts.ObjectLiteralExpression, + propName: string, +): ts.Expression | undefined { + for (const prop of objLiteral.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + let name: string; + if (ts.isIdentifier(prop.name)) name = prop.name.text; + else if (ts.isStringLiteral(prop.name)) name = prop.name.text; + else continue; + if (name === propName) return prop.initializer; + } + return undefined; +} + +// --- JSDOC --- + +/** gathers jsdoc \@param tags to attach to regular params */ +function getJsDocParamDescriptions( + funcNode: ts.FunctionDeclaration, + source: ts.SourceFile, +): Record { + const result: Record = {}; + for (const tag of ts.getJSDocTags(funcNode)) { + if (!ts.isJSDocParameterTag(tag) || !ts.isIdentifier(tag.name)) continue; + const raw = tag.comment; + const desc = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join("") + )?.trim(); + if (desc) result[tag.name.text] = desc; + } + return result; +} + +// --- TYPE PARSING --- + +function entityNameText(name: ts.EntityName): string { + if (ts.isIdentifier(name)) return name.text; + return entityNameText(name.left) + "." + name.right.text; +} + +// typeSource: the source file where typeNode is defined. +// defaultSource: the source file where defaultExpr is defined (always the main source file). +// These are tracked explicitly because ts.createProgram does not set parent nodes, +// so node.getSourceFile() and node.getText() without arguments are unavailable. +function parseTypeNode( + typeNode: ts.TypeNode | undefined, + typeSource: ts.SourceFile, + defaultExpr: ts.Expression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): ParameterInfo { + const info: Partial = {}; + if (defaultExpr) info.default = defaultExpr.getText(defaultSource); + + if (!typeNode) { + info.type = "unknown"; + return info as ParameterInfo; + } + + if (ts.isArrayTypeNode(typeNode)) { + info.array = true; + const inner = parseTypeNode(typeNode.elementType, typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); + info.type = inner.type; + if (inner.nested) info.nested = inner.nested; + return info as ParameterInfo; + } + + if (ts.isTypeReferenceNode(typeNode)) { + const typeName = entityNameText(typeNode.typeName); + + if (typeName === "Array" && typeNode.typeArguments?.length === 1) { + info.array = true; + const inner = parseTypeNode(typeNode.typeArguments[0], typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); + info.type = inner.type; + if (inner.nested) info.nested = inner.nested; + return info as ParameterInfo; + } + + info.type = typeName; + + if (!visited.has(typeName)) { + const entry = interfaceMap.get(typeName); + if (entry) { + const defaultObjLiteral = + defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; + info.nested = parseInterfaceMembers( + entry, + defaultObjLiteral, + defaultSource, + interfaceMap, + new Set(visited).add(typeName), + usageMap, + ); + } + } + + return info as ParameterInfo; + } + + if (ts.isTypeLiteralNode(typeNode)) { + info.type = printer + .printNode(ts.EmitHint.Unspecified, typeNode, typeSource) + .replace(/\s*\n\s*/g, " ") + .replace(/; \}/g, " }") + .replace(/; /g, ", ") + .trim(); + const defaultObjLiteral = + defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; + info.nested = parseTypeLiteralMembers(typeNode, typeSource, defaultObjLiteral, defaultSource, interfaceMap, visited, usageMap); + return info as ParameterInfo; + } + + info.type = printer.printNode(ts.EmitHint.Unspecified, typeNode, typeSource).trim(); + return info as ParameterInfo; +} + +function parseInterfaceMembers( + entry: InterfaceEntry, + defaultObjLiteral: ts.ObjectLiteralExpression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): Record { + const result: Record = {}; + const { decl: interfaceDecl, source: memberSource } = entry; + for (const member of interfaceDecl.members) { + if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; + const name = member.name.text; + const memberDefault = defaultObjLiteral + ? getPropertyExpression(defaultObjLiteral, name) + : undefined; + const info = parseTypeNode(member.type, memberSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); + const desc = extractJsDocComment(member, memberSource); + if (desc) info.description = desc; + if (!info.default) { + const defaultTag = ts.getJSDocTags(member).find((t) => t.tagName.text === "default"); + if (defaultTag) { + const raw = defaultTag.comment; + const val = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(memberSource)).join("") + )?.trim(); + if (val) info.default = val; + } + } + // intentionally not recorded in usageMap: hoisting only applies to types used directly + // as a function parameter (see parseFunctionParams), not to types nested inside another + // interface's members -- otherwise an interface nested inside an already-hoisted interface + // would appear to have 2+ usages (once per re-expansion of the outer interface) and get + // spuriously hoisted into its own orphaned, duplicated section. + result[name] = info; + } + return result; +} + +function parseTypeLiteralMembers( + typeNode: ts.TypeLiteralNode, + typeSource: ts.SourceFile, + defaultObjLiteral: ts.ObjectLiteralExpression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): Record { + const result: Record = {}; + for (const member of typeNode.members) { + if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; + const name = member.name.text; + const memberDefault = defaultObjLiteral + ? getPropertyExpression(defaultObjLiteral, name) + : undefined; + const info = parseTypeNode(member.type, typeSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); + const desc = extractJsDocComment(member, typeSource); + if (desc) info.description = desc; + // see comment in parseInterfaceMembers: nested member usage is intentionally not recorded + result[name] = info; + } + return result; +} + +// --- FUNCTION PARSING --- + +function parseFunctionParams( + funcNode: ts.FunctionDeclaration, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): Record { + const result: Record = {}; + const params = [...funcNode.parameters]; + const paramDescs = getJsDocParamDescriptions(funcNode, source); + for (const param of params) { + if (!ts.isIdentifier(param.name)) continue; + const name = param.name.text; + const defaultExpr = param.initializer + ? resolveDefaultExpr(param.initializer, source) + : undefined; + const info = parseTypeNode(param.type, source, defaultExpr, source, interfaceMap, new Set(), usageMap); + const desc = paramDescs[name]; + if (desc) info.description = desc; + recordInterfaceUsage(usageMap, interfaceMap, info); + result[name] = info; + } + return result; +} + +function parseHelperFunction( + funcNode: ts.FunctionDeclaration, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): TimelineHelperInfo { + return { + description: extractJsDocComment(funcNode, source) ?? "", + helperParameters: parseFunctionParams(funcNode, source, interfaceMap, usageMap), + }; +} + +// --- SOURCE TRAVERSAL --- + +/** @returns a map associating names of functions with their function declarations */ +function buildFunctionMap(source: ts.SourceFile): Map { + const map = new Map(); + for (const stmt of source.statements) { + if (ts.isFunctionDeclaration(stmt) && stmt.name) { + map.set(stmt.name.text, stmt); + } + } + return map; +} + +/** @returns a list of function names from a given timeline object export */ +function collectExportedFunctionNames(source: ts.SourceFile, exportName: string): string[] { + for (const stmt of source.statements) { + if (!ts.isVariableStatement(stmt)) continue; + if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName) continue; + if (!decl.initializer || !ts.isObjectLiteralExpression(decl.initializer)) continue; + return decl.initializer.properties + .filter((p): p is ts.ShorthandPropertyAssignment => ts.isShorthandPropertyAssignment(p)) + .map((p) => p.name.text); + } + } + return []; +} + +// --- EXPORTED API --- + +function findCreateTimeline(source: ts.SourceFile): ts.FunctionDeclaration { + for (const stmt of source.statements) { + if ( + ts.isFunctionDeclaration(stmt) && + stmt.name?.text === "createTimeline" && + stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + return stmt; + } + } + throw new Error(`Could not find exported createTimeline function in: ${source.fileName}`); +} + +/** + * modifies `result` to hoist interfaces used in 2 or more times across timeline functions. + * + * every `ParameterInfo` that contains a to-be-hoisted interface will have its `nested` + * field deleted and replaced with an `interfaceRef` to the given hoisted interface. + */ +function hoistSharedInterfaces( + result: TimelineInfo, + usageMap: UsageMap, + interfaceMap: Map, + source: ts.SourceFile, +): void { + for (const [name, sites] of usageMap) { + if (sites.length < 2) continue; + const entry = interfaceMap.get(name); + if (!entry) continue; + + const interfaceParameters = parseInterfaceMembers( + entry, + undefined, + source, + interfaceMap, + new Set([name]), + usageMap, + ); + const info: TimelineInterfaceInfo = { + description: extractJsDocComment(entry.decl, entry.source) ?? "", + interfaceParameters, + }; + result.interfaces[name] = info; + + for (const site of sites) { + delete site.nested; + site.interfaceRef = name; + } + } +} + +export function getTimelineInfo(filePath: string): TimelineInfo { + const program = ts.createProgram([filePath], { + target: ts.ScriptTarget.Latest, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + }); + // ts.createProgram's default host parses source files without setting parent + // pointers; triggering the checker runs binding, which sets them. Required for + // node.getText()/getSourceFile() and JSDoc lookups (ts.getJSDocCommentsAndTags + // walks node.parent) to work. + program.getTypeChecker(); + + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) throw new Error(`Could not load source file: ${filePath}`); + + const result: TimelineInfo = { name: "", description: "", version: "", createTimeline: { description: "", helperParameters: {} }, timelineUnits: {}, utils: {}, + interfaces: {}, examples: {}, }; + const interfaceMap = buildInterfaceMap(sourceFile, program); + const funcMap = buildFunctionMap(sourceFile); + const createTimelineNode = findCreateTimeline(sourceFile); + const usageMap: UsageMap = new Map(); + + result.createTimeline = parseHelperFunction(createTimelineNode, sourceFile, interfaceMap, usageMap); + + for (const name of collectExportedFunctionNames(sourceFile, "timelineUnits")) { + const func = funcMap.get(name); + if (func) result.timelineUnits[name] = parseHelperFunction(func, sourceFile, interfaceMap, usageMap); + } + + for (const name of collectExportedFunctionNames(sourceFile, "utils")) { + const func = funcMap.get(name); + if (func) result.utils[name] = parseHelperFunction(func, sourceFile, interfaceMap, usageMap); + } + + hoistSharedInterfaces(result, usageMap, interfaceMap, sourceFile); return result; } -function inferCodeBlock(content: string, path: string): string { - return ""; +/** + * generates a `inferCodeBlock` function with existing `TimelineInfo`, used to + * find all usages of `.createTimeline(...)`, alongside any exported + * functions from `timelineUnits` and `utils`. does one level of indirection, + * and deduplicates in case something like a `utils` function is found stuck in + * a config file. + */ +function makeInferCodeBlock(info: TimelineInfo) { + const utilNames = new Set(Object.keys(info.utils)); + const unitNames = new Set(Object.keys(info.timelineUnits)); + + return function inferCodeBlock(sourceContent: string, sourcePath: string): string { + const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; + const blocks: string[] = []; + let match: RegExpExecArray | null; + while ((match = scriptRegex.exec(sourceContent)) !== null) blocks.push(match[1]); + + if (blocks.length === 0) throw new Error(`${sourcePath}: no inline script blocks found`); + if (blocks.length > 1) + throw new Error( + `${sourcePath}: multiple inline script blocks found, use jspsych-autodoc:start/end sentinels instead`, + ); + + const scriptContent = blocks[0]; + const sourceFile = ts.createSourceFile("example.js", scriptContent, ts.ScriptTarget.Latest, true); + + // walks up to the direct child of sourceFile that contains `node` + function topLevelStatement(node: ts.Node): ts.Node { + let current = node; + while (current.parent !== sourceFile) current = current.parent; + return current; + } + + // statements containing a createTimeline()/utils/timelineUnits call, keyed by position to dedupe + const coreStatements = new Map(); + const coreCalls: ts.CallExpression[] = []; + + function visit(node: ts.Node) { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const access = node.expression; + const isCreateTimeline = access.name.text === "createTimeline"; + const isCoreHelper = + ts.isPropertyAccessExpression(access.expression) && + ((access.expression.name.text === "utils" && utilNames.has(access.name.text)) || + (access.expression.name.text === "timelineUnits" && unitNames.has(access.name.text))); + + if (isCreateTimeline || isCoreHelper) { + const stmt = topLevelStatement(node); + coreStatements.set(stmt.pos, stmt); + coreCalls.push(node); + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + + if (coreStatements.size === 0) + throw new Error( + `${sourcePath}: no createTimeline()/utils/timelineUnits usage found — use jspsych-autodoc:start/end sentinels instead`, + ); + + // build map of all local variable declarations, excluding statements already matched above + const localDecls = new Map(); + function visitDecls(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const stmt = node.parent.parent; + if (ts.isVariableStatement(stmt) && !coreStatements.has(stmt.pos)) { + localDecls.set(node.name.text, stmt); + } + } + ts.forEachChild(node, visitDecls); + } + visitDecls(sourceFile); + + // Collect identifier references from a node, skipping property assignment keys + function collectIdentifiers(node: ts.Node, result: Set) { + if (ts.isPropertyAssignment(node)) { + collectIdentifiers(node.initializer, result); + return; + } + if (ts.isIdentifier(node)) { + result.add(node.text); + return; + } + ts.forEachChild(node, (child) => collectIdentifiers(child, result)); + } + + // gather matched statements and their one-level dependencies (e.g. a config object + // passed into createTimeline), keyed by position to deduplicate + const outputStatements = new Map(coreStatements); + for (const call of coreCalls) { + const refs = new Set(); + for (const arg of call.arguments) collectIdentifiers(arg, refs); + for (const ref of refs) { + const decl = localDecls.get(ref); + if (decl) outputStatements.set(decl.pos, decl); + } + } + + return Array.from(outputStatements.values()) + .sort((a, b) => a.pos - b.pos) + .map((node) => dedent(node.getFullText(sourceFile))) + .join("\n\n"); + }; } -export function getTimelineInfoAndExamples( - sourceFile: ts.SourceFile, - createTimelineNode: ts.FunctionDeclaration, - examplePath: string, -): TimelineInfo { - const info = getTimelineInfo(sourceFile, createTimelineNode); - info.examples = collectExamples(examplePath, inferCodeBlock); +export function getTimelineInfoAndExamples(filePath: string, examplePath: string): TimelineInfo { + const info = getTimelineInfo(filePath); + info.examples = collectExamples(examplePath, makeInferCodeBlock(info)); return info; } diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 16c855e..cc1e378 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -6,7 +6,7 @@ import { ExampleInfo, ParameterInfo } from "../types/info.js"; // --- PARSE SRC FILE UTILS --- /** thing that removes comments from nested parameter types */ -const printer = ts.createPrinter({ removeComments: true }); +export const printer = ts.createPrinter({ removeComments: true }); /** Grabs JSDoc comments from a node. */ export function extractJsDocComment(node: ts.Node, source: ts.SourceFile): string | undefined { @@ -37,7 +37,6 @@ export function parseParamGroup( return result; } - /** Gathers parameter information (type, default, etc.) from a node. */ function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFile): ParameterInfo { const result: Partial = {}; diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts index 7d2f9da..43e25e8 100644 --- a/packages/autodoc/src/renderers/timeline.ts +++ b/packages/autodoc/src/renderers/timeline.ts @@ -1,11 +1,142 @@ -import { SectionTemplate, TimelineInfo } from "../types/info.js"; +import { SectionTemplate, TimelineInfo, TimelineHelperInfo, TimelineInterfaceInfo, ParameterInfo } from "../types/info.js"; +import { topParameterChart } from "./utils.js"; + +const getTypeName = (type: string, array?: boolean): string => (array ? `array of ${type}` : type); + +function formatDefault(value: string | undefined): string { + if (!value) return "*(required)*"; + // no new lines!!!!!!!! + const normalized = value.replace(/\s*\n\s*/g, " ").trim(); + return !isNaN(parseFloat(normalized)) ? normalized : `\`${normalized}\``; +} + +function renderParamRow(name: string, param: ParameterInfo): string { + let description = param.description?.trim(); + if (!description) { + console.warn(`Warning: Parameter "${name}" is missing a description.`); + description = "No description provided."; + } + if (param.interfaceRef) { + description = `${description} (see [\`${param.interfaceRef}\`](#${param.interfaceRef.toLowerCase()}) below)`; + } + return `| ${name} | ${getTypeName(param.type, param.array)} | ${formatDefault(param.default)} | ${description} |`; +} + +function renderParameterChart( + params: Record, + subHeadingLevel: string, + path: string, +): string { + const rows = Object.entries(params) + .map(([name, param]) => renderParamRow(name, param)) + .join("\n"); + const chart = `${topParameterChart}\n${rows || "*None*"}`; + + const subcharts = Object.entries(params) + .filter(([, param]) => param.nested) + .map(([name, param]) => { + const subPath = path ? `${path}.${name}` : name; + return `${subHeadingLevel} \`${subPath}\`\n\n${renderParameterChart(param.nested!, subHeadingLevel + "#", subPath)}`; + }); + + return [chart, ...subcharts].join("\n\n"); +} + +function renderFunctionBody(helper: TimelineHelperInfo, subHeadingLevel: string): string { + const description = helper.description || "*No description provided.*"; + const chart = renderParameterChart(helper.helperParameters, subHeadingLevel, ""); + return `${description}\n\n${chart}`; +} + +function renderHelperGroup(group: Record): string { + const sections = Object.entries(group) + .map(([name, helper]) => `#### \`${name}()\`\n\n${renderFunctionBody(helper, "#####")}`) + .join("\n\n"); + return sections || "*None*"; +} const mainTemplate: SectionTemplate[] = [ - { - heading: "", - render: (info) => "" - } -] + { + heading: "introduction", + render: (info) => { + return `# ${info.name} + +${info.description} + +Current version: ${info.version}`.trim(); + }, + }, + { + heading: "installation", + render: (info) => { + return `## Installation + +TODO` + } + }, + { + heading: "api-reference", + render: (_) => "## API Reference", + }, + { + heading: "create-timeline", + render: (info) => `### \`createTimeline()\` + +${renderFunctionBody(info.createTimeline, "####")}`, + }, + { + heading: "timeline-units", + render: (info) => `### \`timelineUnits\` + +The following helper functions are exported as part of \`timelineUnits\` and can be used to build pieces of the timeline. + +${renderHelperGroup(info.timelineUnits)}`, + }, + { + heading: "utils", + render: (info) => `### \`utils\` + +The following helper functions are exported as part of \`utils\`. + +${renderHelperGroup(info.utils)}`, + }, + { + heading: "configuration-options", + render: (info) => { + const sections = Object.entries(info.interfaces) + .map(([name, interfaceInfo]: [string, TimelineInterfaceInfo]) => { + const description = interfaceInfo.description || "*No description provided.*"; + const chart = renderParameterChart(interfaceInfo.interfaceParameters, "####", ""); + return `### \`${name}\`\n\n${description}\n\n${chart}`; + }) + .join("\n\n"); + return `## Configuration Options + +These types are shared by multiple parameters above. + +${sections || "*None*"}`; + }, + }, + { + heading: "examples", + render: (info) => { + const sections = Object.entries(info.examples) + .map( + ([title, example]) => + `### ${title} (${example.path}) + +\`\`\`js +${example.code} +\`\`\``, + ) + .join("\n\n"); + return `## Examples + +${sections} +`.trim(); + }, + }, +]; export function getTimelineDocs(info: TimelineInfo): Record { return Object.fromEntries( @@ -15,4 +146,4 @@ export function getTimelineDocs(info: TimelineInfo): Record { return [section.heading, wrapped]; }), ); -} \ No newline at end of file +} diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts index 1f884a1..728409b 100644 --- a/packages/autodoc/src/types/info.ts +++ b/packages/autodoc/src/types/info.ts @@ -1,7 +1,7 @@ export interface PluginInfo { name: string; description: string; - version: string; + version: string; // gathered from package.json parameters: Record; data: Record; examples: Record; @@ -10,7 +10,7 @@ export interface PluginInfo { export interface ExtensionInfo { name: string; description: string; - version: string; + version: string; // gathered from package.json initializeParameters: Record; onStartParameters: Record; onLoadParameters: Record; @@ -20,31 +20,41 @@ export interface ExtensionInfo { } export interface TimelineInfo { - name: string; - description: string; // TODO: just gather this from package.json - version: string; + name: string; // gathered from package.json, since createTimeline has no equivalent of a plugin's `info.name` + description: string; // gathered from package.json, since createTimeline has no class-level JSDoc to extract + version: string; // also gathered from package.json createTimeline: TimelineHelperInfo; // only one: no need to attach name timelineUnits: Record; utils: Record; + /** common interfaces used by 2 or more functions */ + interfaces: Record; examples: Record; } -// name is attached via Record +/** name is attached via record */ +export interface TimelineInterfaceInfo { + description: string; + interfaceParameters: Record; +} + +/** name is attached via record */ export interface TimelineHelperInfo { description: string; helperParameters: Record; } -// name is attached via Record +/** name is attached via record */ export interface ParameterInfo { type: string; default: string; array?: boolean; description?: string; nested?: Record; + /** used instead of `nested` if an interface is used across more than 2 functions (for timeline parsing) */ + interfaceRef?: string; } -// name is attached via Record +/** name is attached via record */ export interface ExampleInfo { path: string; code: string; diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts index 37606b9..a5d6df7 100644 --- a/packages/autodoc/src/utils.ts +++ b/packages/autodoc/src/utils.ts @@ -130,22 +130,35 @@ export function identifyPackageType(source: ts.SourceFile): { return result; } -/** Gathers the version number from a package.json file found in the current working directory. */ -export function extractVersionFromPackageJson(): string { +export interface PackageJsonInfo { + name: string; + description: string; + version: string; +} + +/** + * gathers name, description, and version from package-json, using either a given + * path to one, or attempting to infer via checking the root directory. + */ +export function extractPackageJsonInfo(packageJsonPath?: string): PackageJsonInfo { + const resolvedPath = packageJsonPath ?? path.join(process.cwd(), "package.json"); try { - const pluginPackageJson = JSON.parse( - fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"), - ); - if (pluginPackageJson.version) { - return pluginPackageJson.version; - } else { - console.warn("Warning: No version field found in package.json."); - return "unknown version"; - } + const packageJson = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + + if (!packageJson.name) console.warn("Warning: No name field found in package.json."); + if (!packageJson.description) console.warn("Warning: No description field found in package.json."); + if (!packageJson.version) console.warn("Warning: No version field found in package.json."); + + return { + name: packageJson.name ?? "unknown name", + description: packageJson.description ?? "unknown description", + version: packageJson.version ?? "unknown version", + }; } catch (err) { console.warn( - "Warning: Could not read package.json to determine version. Ensure you are running the CLI in the directory that contains the package.json.", + `Warning: Could not read package.json at ${resolvedPath} to determine package info. ` + + "Ensure you are running the CLI in the directory that contains the package.json, or provide --package-json.", ); - return "unknown version"; + return { name: "unknown name", description: "unknown description", version: "unknown version" }; } } diff --git a/packages/autodoc/tests/fixtures/timeline/basic.ts b/packages/autodoc/tests/fixtures/timeline/basic.ts index 61a0c71..a524562 100644 --- a/packages/autodoc/tests/fixtures/timeline/basic.ts +++ b/packages/autodoc/tests/fixtures/timeline/basic.ts @@ -1,27 +1,82 @@ import { JsPsych } from "../../utils.js"; +/** little configuration nation */ +interface StimulusConfig { + /** some stuff */ + stuff: number; + /** some things */ + things: boolean; +} + +/** bigggg configggg */ +interface BigConfig { + /** big thing */ + big: string; + /** little thing */ + little: number; + /** text thing */ + extra: StimulusConfig; +} + +/** + * creates a fixation. Gasp. + * + * @param fixation text to be shown + * @param duration time to show fixation + */ +function createFixation(fixation: string, duration: number) { -function createIntroduction({}) { +} + +/** + * doohickeyinator + */ +function createStimulus({ + stimuli, duration, reverse +}: { + stimuli: string[], + duration: Array, + reverse: boolean +}) { } -function createDoohickey({}) { +/** + * AAAA + * @param hello HELP ME + */ +function createFeedbackTrial(feedbackMessage: string, canShow: boolean) { + +} +/** + * can you show the creature or not + */ +function canShowCreature(sentiment: string, config?: BigConfig ): boolean { + return true; } -function doohickeyHelper({}) { +function notShown() { } -export function createTimeline(jsPsych: JsPsych, {}) { +/** + * Generates a really cool and awesome thing + */ +export function createTimeline(jsPsych: JsPsych, config: BigConfig = { + big: "hello", + little: -99, + extra: { stuff: 3, things: true } +}) { } export const timelineUnits = { - createIntroduction, - createDoohickey, + createFixation, + createStimulus, + createFeedbackTrial } export const utils = { - doohickeyHelper, + canShowCreature } diff --git a/packages/autodoc/tests/fixtures/timeline/examples/complex-inferred-example.html b/packages/autodoc/tests/fixtures/timeline/examples/complex-inferred-example.html new file mode 100644 index 0000000..5f8af3b --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/examples/complex-inferred-example.html @@ -0,0 +1,37 @@ + + + + complex inferred example + + + + + diff --git a/packages/autodoc/tests/fixtures/timeline/examples/complex-sentinel-example.html b/packages/autodoc/tests/fixtures/timeline/examples/complex-sentinel-example.html new file mode 100644 index 0000000..ef2ae06 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/examples/complex-sentinel-example.html @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/timeline/examples/ignored-example.html b/packages/autodoc/tests/fixtures/timeline/examples/ignored-example.html new file mode 100644 index 0000000..bc13827 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/examples/ignored-example.html @@ -0,0 +1,14 @@ + + + + + ignored example + + + + + diff --git a/packages/autodoc/tests/fixtures/timeline/examples/simple-inferred-example.html b/packages/autodoc/tests/fixtures/timeline/examples/simple-inferred-example.html new file mode 100644 index 0000000..589b01f --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/examples/simple-inferred-example.html @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/timeline/examples/simple-sentinel-example.html b/packages/autodoc/tests/fixtures/timeline/examples/simple-sentinel-example.html new file mode 100644 index 0000000..8d95619 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/examples/simple-sentinel-example.html @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/parsers/timeline.test.ts b/packages/autodoc/tests/parsers/timeline.test.ts index d46f7ac..f2a9b79 100644 --- a/packages/autodoc/tests/parsers/timeline.test.ts +++ b/packages/autodoc/tests/parsers/timeline.test.ts @@ -1,3 +1,113 @@ -describe('timeline parser', () => { - test.todo('parses timeline info'); +import { getTimelineInfo, getTimelineInfoAndExamples } from '../../src/parsers/timeline.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.resolve(__dirname, '../fixtures/timeline/basic.ts'); + +describe('getTimelineInfo', () => { + it('extracts createTimeline helperParameters with types and defaults', () => { + const info = getTimelineInfo(fixturePath); + expect(info.createTimeline.description).toBe('Generates a really cool and awesome thing'); + expect(info.createTimeline.helperParameters.jsPsych.type).toBe('JsPsych'); + expect(info.createTimeline.helperParameters.config.type).toBe('BigConfig'); + expect(info.createTimeline.helperParameters.config.default).toBe( + '{\n big: "hello",\n little: -99,\n extra: { stuff: 3, things: true }\n}' + ); + }); + + it('extracts timelineUnits functions with JSDoc descriptions and param descriptions', () => { + const info = getTimelineInfo(fixturePath); + expect(info.timelineUnits.createFixation.description).toBe('creates a fixation. Gasp.'); + expect(info.timelineUnits.createFixation.helperParameters.fixation.type).toBe('string'); + expect(info.timelineUnits.createFixation.helperParameters.fixation.description).toBe('text to be shown'); + expect(info.timelineUnits.createFixation.helperParameters.duration.type).toBe('number'); + expect(info.timelineUnits.createFixation.helperParameters.duration.description).toBe('time to show fixation'); + + expect(info.timelineUnits.createFeedbackTrial.description).toBe('AAAA'); + expect(info.timelineUnits.createFeedbackTrial.helperParameters.feedbackMessage.type).toBe('string'); + expect(info.timelineUnits.createFeedbackTrial.helperParameters.canShow.type).toBe('boolean'); + }); + + it('excludes functions not present in the exported timelineUnits/utils objects', () => { + const info = getTimelineInfo(fixturePath); + expect(info.timelineUnits.notShown).toBeUndefined(); + expect(info.utils.notShown).toBeUndefined(); + }); + + it('extracts utils functions', () => { + const info = getTimelineInfo(fixturePath); + expect(info.utils.canShowCreature.description).toBe('can you show the creature or not'); + expect(info.utils.canShowCreature.helperParameters.sentiment.type).toBe('string'); + }); + + it('hoists an interface used by 2 or more functions, replacing nested with interfaceRef', () => { + const info = getTimelineInfo(fixturePath); + expect(info.interfaces.BigConfig).toBeDefined(); + expect(info.interfaces.BigConfig.description).toBe('bigggg configggg'); + expect(info.interfaces.BigConfig.interfaceParameters.big.type).toBe('string'); + expect(info.interfaces.BigConfig.interfaceParameters.little.type).toBe('number'); + expect(info.interfaces.BigConfig.interfaceParameters.extra.type).toBe('StimulusConfig'); + + expect(info.createTimeline.helperParameters.config.interfaceRef).toBe('BigConfig'); + expect(info.createTimeline.helperParameters.config.nested).toBeUndefined(); + expect(info.utils.canShowCreature.helperParameters.config.interfaceRef).toBe('BigConfig'); + expect(info.utils.canShowCreature.helperParameters.config.nested).toBeUndefined(); + }); + + it('does not hoist an interface that is only referenced as a nested field of an already-hoisted interface', () => { + const info = getTimelineInfo(fixturePath); + expect(info.interfaces.StimulusConfig).toBeUndefined(); + expect(Object.keys(info.interfaces)).toEqual(['BigConfig']); + expect(info.interfaces.BigConfig.interfaceParameters.extra.interfaceRef).toBeUndefined(); + expect(info.interfaces.BigConfig.interfaceParameters.extra.nested).toEqual({ + stuff: { type: 'number', description: 'some stuff' }, + things: { type: 'boolean', description: 'some things' }, + }); + }); +}); + +describe('getTimelineInfoAndExamples', () => { + const examplesDir = path.resolve(__dirname, '../fixtures/timeline/examples'); + + it('should extract examples from a provided file', () => { + const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); + const info = getTimelineInfoAndExamples(fixturePath, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['simple sentinel example']).toBeDefined(); + expect(info.examples['simple sentinel example'].path).toBe(filePath); + expect(info.examples['simple sentinel example'].code).toBe( + 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' + ); + }); + + it('should extract examples from a provided directory', () => { + const info = getTimelineInfoAndExamples(fixturePath, examplesDir); + expect(Object.keys(info.examples)).toHaveLength(4); + expect(info.examples['ignored example']).toBeUndefined(); + + const simpleSentinelExample = info.examples['simple sentinel example']; + expect(simpleSentinelExample.path).toBe(path.join(examplesDir, 'simple-sentinel-example.html')); + expect(simpleSentinelExample.code).toBe( + 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' + ); + + const complexSentinelExample = info.examples['complex sentinel example']; + expect(complexSentinelExample.path).toBe(path.join(examplesDir, 'complex-sentinel-example.html')); + expect(complexSentinelExample.code).toBe( + 'var fixationTrial = jsPsychTestTimeline.createFixationTrial("+");\n\nvar stimulusTrial = jsPsychTestTimeline.createStimulusTrial(\n "hello",\n 12,\n true\n)\n\nvar feedbackTrial = jsPsychTestTimeline.createFeedbackTrial(false);' + ); + + const simpleInferredExample = info.examples['simple inferred example']; + expect(simpleInferredExample.path).toBe(path.join(examplesDir, 'simple-inferred-example.html')); + expect(simpleInferredExample.code).toBe( + 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' + ); + + const complexInferredExample = info.examples['complex inferred example']; + expect(complexInferredExample.path).toBe(path.join(examplesDir, 'complex-inferred-example.html')); + expect(complexInferredExample.code).toBe( + 'const fixationConfig = {\n fixation: "+",\n duration: 1000,\n}\n\nconst fixationTrial = jsPsychTestTimeline.timelineUnits.createFixation(fixationConfig);\n\nconst stimulusConfig = {\n stimuli: ["hello", "cheese wheel"],\n duration: [250, 1000],\n}\n\nconst stimulusTrial = jsPsychTestTimeline.timelineUnits.createStimulus({\n ...stimulusConfig, \n reverse: true\n})\n\nlet feedbackMessage = "dog";\n\nconst feedbackTrial = jsPsychTestTimeline.timelineUnits.createFeedbackTrial({\n feedback: feedbackMessage,\n showCreature: jsPsychTestTimeline.utils.canShowCreature("maybe")\n})' + ); + }); }); From f4df8583a182f7f211f4ac51ffd16a0c71752526 Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:03:31 -0400 Subject: [PATCH 08/26] add file discovery for running CLI without any flags allows the CLI tool to discover the source, destination, example (optional), and use it. also adds tests for this functionality, and e2e CLI testing in general --- packages/autodoc/src/cli.ts | 125 ++++++++++----- packages/autodoc/src/utils.ts | 85 +++++++++- packages/autodoc/tests/cli.test.ts | 175 ++++++++++++++++----- packages/autodoc/tests/discovery.test.ts | 146 +++++++++++++++++ packages/autodoc/tests/helpers/tempTree.ts | 27 ++++ packages/autodoc/tests/utils.test.ts | 55 +++++++ 6 files changed, 530 insertions(+), 83 deletions(-) create mode 100644 packages/autodoc/tests/discovery.test.ts create mode 100644 packages/autodoc/tests/helpers/tempTree.ts create mode 100644 packages/autodoc/tests/utils.test.ts diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index bfe029b..60d1456 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -7,7 +7,14 @@ import ts from "typescript"; import { Command } from "commander"; -import { extractPackageJsonInfo, identifyPackageType, updateDocSections } from "./utils.js"; +import { + discoverDest, + discoverExample, + discoverSource, + extractPackageJsonInfo, + identifyPackageType, + updateDocSections, +} from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; import { getPluginDocs } from "./renderers/plugin.js"; import { getExtensionInfo, getExtensionInfoAndExamples } from "./parsers/extension.js"; @@ -30,20 +37,42 @@ interface CliOptions { dest?: string; example?: string; packageJson?: string; + dryRun?: boolean; } // TODO: simulation mode-- detect if simulation mode is supported via these plugins. function main(options: CliOptions): void { - let sourcePath: string; - if (options.source) { - sourcePath = options.source; - } else { - throw new Error("No source file provided. Please specify a source file with --source."); - } - if (!options.dest) { - throw new Error("No destination file provided. Please specify a destination file with --dest."); - } + let cachedAnchor: string | undefined; + + /** cwd if it is a package root (contains package.json), else undefined, memoized. */ + const anchorOrNull = (): string | undefined => { + if (cachedAnchor) return cachedAnchor; + const cwd = process.cwd(); + if (!fs.existsSync(path.join(cwd, "package.json"))) return undefined; + return (cachedAnchor = path.resolve(cwd)); + }; + + /** like `anchorOrNull`, for inputs that genuinely require discovery (throw if null) */ + const anchor = (): string => { + const a = anchorOrNull(); + if (!a) { + throw new Error( + "No package.json found in the current directory. " + + "Run autodoc from the root of the package you want to document, " + + "or pass --source/--dest/--package-json explicitly.", + ); + } + return a; + }; + + const sourcePath = options.source ?? discoverSource(anchor()); + const packageJsonPath = options.packageJson ?? path.join(anchor(), "package.json"); + + // example discovery is optional, so shouldn't really have to deal w/ fail-fast + // behavior if not necessary + const exampleAnchor = anchorOrNull(); + const examplePath = options.example ?? (exampleAnchor ? discoverExample(exampleAnchor) : undefined); const source = ts.createSourceFile( sourcePath, @@ -53,46 +82,56 @@ function main(options: CliOptions): void { ); const { mainNode, type } = identifyPackageType(source); - console.log(type) + const packageJsonInfo = extractPackageJsonInfo(packageJsonPath); + + // dest depends on the resolved type + package name, so it resolves last. + const destPath = options.dest ?? discoverDest(anchor(), packageJsonInfo.name, type); + + const mark = (explicit: boolean) => (explicit ? "explicit" : "discovered"); + console.log("Resolved inputs:"); + console.log(` type ${type}`); + console.log(` source ${sourcePath} (${mark(!!options.source)})`); + console.log(` dest ${destPath} (${mark(!!options.dest)})`); + console.log(` package.json ${packageJsonPath} (${mark(!!options.packageJson)})`); + console.log(` example ${examplePath ?? "(none)"} (${mark(!!options.example)})`); + + if (options.dryRun) { + console.log("\n--dry-run: no files written."); + return; + } + let docs: Record; if (type === "extension") { - console.log("Identified package type: extension"); - let extensionInfo: ExtensionInfo; - if (options.example) { - extensionInfo = getExtensionInfoAndExamples(source, mainNode as ts.ClassDeclaration, options.example); + if (examplePath) { + extensionInfo = getExtensionInfoAndExamples(source, mainNode as ts.ClassDeclaration, examplePath); } else { extensionInfo = getExtensionInfo(source, mainNode as ts.ClassDeclaration); } - extensionInfo.version = extractPackageJsonInfo(options.packageJson).version; + extensionInfo.version = packageJsonInfo.version; docs = getExtensionDocs(extensionInfo); } else if (type === "plugin") { - console.log("Identified package type: plugin"); - let pluginInfo: PluginInfo; - if (options.example) { - pluginInfo = getPluginInfoAndExamples(source, mainNode as ts.ClassDeclaration, options.example); + if (examplePath) { + pluginInfo = getPluginInfoAndExamples(source, mainNode as ts.ClassDeclaration, examplePath); } else { pluginInfo = getPluginInfo(source, mainNode as ts.ClassDeclaration); } - pluginInfo.version = extractPackageJsonInfo(options.packageJson).version; + pluginInfo.version = packageJsonInfo.version; docs = getPluginDocs(pluginInfo); } else if (type === "timeline") { - console.log("Identified package type: timeline"); - let timelineInfo: TimelineInfo; - if (options.example) { - timelineInfo = getTimelineInfoAndExamples(options.source, options.example); + if (examplePath) { + timelineInfo = getTimelineInfoAndExamples(sourcePath, examplePath); } else { - timelineInfo = getTimelineInfo(options.source); + timelineInfo = getTimelineInfo(sourcePath); } - const packageJsonInfo = extractPackageJsonInfo(options.packageJson); timelineInfo.name = packageJsonInfo.name; timelineInfo.description = packageJsonInfo.description; timelineInfo.version = packageJsonInfo.version; @@ -103,15 +142,15 @@ function main(options: CliOptions): void { } const rawContent = Object.values(docs).join("\n\n"); - if (!fs.existsSync(options.dest)) { - fs.writeFileSync(options.dest, rawContent, "utf8"); + if (!fs.existsSync(destPath)) { + fs.writeFileSync(destPath, rawContent, "utf8"); } else { - const existingContent = fs.readFileSync(options.dest, "utf8"); + const existingContent = fs.readFileSync(destPath, "utf8"); if (existingContent.trim() === "") { - fs.writeFileSync(options.dest, rawContent, "utf8"); + fs.writeFileSync(destPath, rawContent, "utf8"); } else { const updatedContent = updateDocSections(existingContent, docs); - fs.writeFileSync(options.dest, updatedContent, "utf8"); + fs.writeFileSync(destPath, updatedContent, "utf8"); } } } @@ -122,14 +161,15 @@ program .name("autodoc") .description("CLI tool to generate documentation for jsPsych plugins") .version(version) - .option("--source ", "Source of the package") - .option("--dest ", "Destination directory for the generated documentation") + .option("--source ", "Source of the package (optional, auto-detected from src/index.ts or index.ts)") + .option("--dest ", "Destination file for the generated documentation (optional, auto-detected from the package's docs)") .option("--repo ", "Repository that contains the source/destination files (optional)") - .option("--example ", "Example folder containing usages of the plugin (optional)") + .option("--example ", "Example folder containing usages of the plugin (optional, auto-detected from examples/)") .option( "--package-json ", - "Path to the package.json to read name/description/version from (optional, defaults to ./package.json)", + "Path to the package.json to read name/description/version from (optional, auto-detected from the package root)", ) + .option("--dry-run", "Print the resolved source/dest/example paths and exit without writing (optional)") .option("-v, --verbose", "Enable verbose logging (optional)") .option( "-f, --force", @@ -140,10 +180,17 @@ program "after", ` Examples: - $ autodoc --source /src/index.ts --dest /docs/index.md - $ autodoc --source /src/index.ts --dest /docs/index.md --example /examples/`, + $ autodoc # run inside a package; everything auto-detected + $ autodoc --dry-run # preview what would be resolved, write nothing + $ autodoc --source src/index.ts --dest docs/index.md + $ autodoc --source src/index.ts --dest docs/index.md --example examples/`, ); program.parse(); const options = program.opts(); -main(options); +try { + main(options); +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts index a5d6df7..f30f22e 100644 --- a/packages/autodoc/src/utils.ts +++ b/packages/autodoc/src/utils.ts @@ -72,7 +72,6 @@ export function updateDocSections(fileContent: string, docs: Record fs.existsSync(p)); + if (!found) { + throw new Error( + `Could not auto-detect a source file (looked for src/index.ts, index.ts under ${anchor}). ` + + "Specify one with --source.", + ); + } + return found; +} + +/** + * @returns `examples/` directory pathway to anchor, undefined if not found (optional) + */ +export function discoverExample(anchor: string): string | undefined { + const dir = path.join(anchor, "examples"); + return fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? dir : undefined; +} + +/** collects `.md` files whose basename (sans extension) matches one of `stems` */ +function findMarkdownByStem(anchor: string, stems: Set): string[] { + const results: string[] = []; + const walk = (dir: string, recurse: boolean) => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (recurse && entry.name !== "node_modules") walk(full, true); + } else if (entry.isFile() && entry.name.endsWith(".md") && stems.has(entry.name.slice(0, -3))) { + results.push(full); + } + } + }; + walk(anchor, false); // top-level files only + walk(path.join(anchor, "docs"), true); // docs/ recursively + return [...new Set(results)]; +} + +/** + * resolves destination file by matching existing md file against package + * name and package type, trying to find one of unscoped name (`plugin-foo`), + * type-stripped name (`foo`), and reconstructed name (`-`). fails + * fast, this is a requirement if not present or if there are multiple matches. + */ +export function discoverDest( + anchor: string, + packageName: string, + type: "plugin" | "extension" | "timeline", +): string { + const unscoped = packageName.replace(/^@[^/]+\//, ""); + const stripped = unscoped.replace(/^(plugin|extension|timeline)-/, ""); + const stems = new Set([unscoped, stripped, `${type}-${stripped}`]); + + const matches = findMarkdownByStem(anchor, stems); + + if (matches.length === 0) { + throw new Error( + `Could not find an existing docs file for "${unscoped}" ` + + `(looked for ${[...stems].map((s) => `${s}.md`).join(", ")} in ${anchor} and ${anchor}/docs). ` + + "Specify the destination with --dest.", + ); + } + if (matches.length > 1) { + throw new Error( + `Multiple candidate docs files found for "${unscoped}":\n` + + matches.map((m) => ` - ${m}`).join("\n") + + "\nDisambiguate with --dest.", + ); + } + return matches[0]; +} + export interface PackageJsonInfo { name: string; description: string; @@ -155,10 +230,10 @@ export function extractPackageJsonInfo(packageJsonPath?: string): PackageJsonInf version: packageJson.version ?? "unknown version", }; } catch (err) { - console.warn( - `Warning: Could not read package.json at ${resolvedPath} to determine package info. ` + - "Ensure you are running the CLI in the directory that contains the package.json, or provide --package-json.", + throw new Error( + `Could not read package.json at ${resolvedPath} to determine package info. ` + + "Ensure you are running the CLI in the directory that contains the package.json, or provide --package-json. " + + `(${err instanceof Error ? err.message : String(err)})`, ); - return { name: "unknown name", description: "unknown description", version: "unknown version" }; } } diff --git a/packages/autodoc/tests/cli.test.ts b/packages/autodoc/tests/cli.test.ts index 6117ad3..a204494 100644 --- a/packages/autodoc/tests/cli.test.ts +++ b/packages/autodoc/tests/cli.test.ts @@ -1,55 +1,152 @@ -import { identifyPackageType } from "../src/utils.js"; -import ts from "typescript"; +import { execFileSync, execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { makeTree, removeTree } from "./helpers/tempTree.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(__dirname, ".."); +const cliPath = path.join(packageRoot, "dist", "cli.js"); -function loadFixture(relativePath: string) { - const fixturePath = path.resolve(__dirname, relativePath); - return ts.createSourceFile( - fixturePath, - fs.readFileSync(fixturePath, "utf-8"), - ts.ScriptTarget.Latest, - true - ); +const pluginSource = fs.readFileSync( + path.join(__dirname, "fixtures", "plugin", "basic.ts"), + "utf-8", +); + +// CLI is a built artifact, so we build it once up front and ensure the dist is not stale. +beforeAll(() => { + execSync("npm run build", { cwd: packageRoot, stdio: "ignore" }); +}, 120_000); + +const roots: string[] = []; +const tree = (spec: Record): string => { + const root = makeTree(spec); + roots.push(root); + return root; +}; +afterEach(() => { + roots.splice(0).forEach(removeTree); +}); + +interface CliResult { + status: number; + stdout: string; + stderr: string; } -const pluginSource = loadFixture("fixtures/plugin/basic.ts"); -const extensionSource = loadFixture("fixtures/extension/basic.ts"); -const timelineSource = loadFixture("fixtures/timeline/basic.ts") -const bothInterfacesSource = loadFixture("fixtures/utils/both-interfaces.ts"); -const noClassSource = loadFixture("fixtures/utils/no-class.ts"); - -describe("identifyPackageType", () => { - it("identifies plugin class", () => { - const result = identifyPackageType(pluginSource); - expect(result.type).toBe("plugin"); - expect(result.mainNode.name?.text).toBe("TestPlugin"); +/** runs the built CLI in `cwd`, capturing status/stdout/stderr without throwing on failure */ +function runCli(args: string[], cwd: string): CliResult { + try { + const stdout = execFileSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], }); + return { status: 0, stdout, stderr: "" }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + status: e.status ?? 1, + stdout: e.stdout?.toString() ?? "", + stderr: e.stderr?.toString() ?? "", + }; + } +} + +const pkgJson = (overrides: Record = {}) => + JSON.stringify({ + name: "@jspsych/plugin-test", + description: "A test plugin", + version: "9.9.9", + ...overrides, + }); - it("identifies extension class", () => { - const result = identifyPackageType(extensionSource); - expect(result.type).toBe("extension"); - expect(result.mainNode.name?.text).toBe("TestExtension"); +describe("cli --dry-run", () => { + it("resolves and reports every input without writing", () => { + const root = tree({ + "package.json": pkgJson(), + "src/index.ts": pluginSource, + "docs/test.md": "", + "examples/demo.html": "", }); - it("identifies timeline function", () => { - const result = identifyPackageType(timelineSource); - expect(result.type).toBe("timeline") - expect(result.mainNode.name?.text).toBe("createTimeline"); - }) + const { status, stdout } = runCli(["--dry-run"], root); - it("throws if class implements both interfaces", () => { - expect(() => identifyPackageType(bothInterfacesSource)).toThrow( - "A class cannot implement both JsPsychPlugin and JsPsychExtension interfaces." - ); - }); + expect(status).toBe(0); + expect(stdout).toMatch(/type\s+plugin/); + expect(stdout).toContain(path.join(root, "src", "index.ts")); + expect(stdout).toContain(path.join(root, "docs", "test.md")); + expect(stdout).toContain(path.join(root, "examples")); + expect(stdout).toContain("no files written"); + // the discovered destination must be untouched + expect(fs.readFileSync(path.join(root, "docs", "test.md"), "utf-8")).toBe(""); + }); + + it("works fully out-of-tree from a non-package cwd without --example", () => { + // source/dest/package-json explicit; example omitted. bc example discovery + // is soft, the missing cwd package.json must NOT cause a failure here. + const pkg = tree({ "package.json": pkgJson(), "src/index.ts": pluginSource }); + const dest = path.join(pkg, "out.md"); + fs.writeFileSync(dest, ""); + const elsewhere = tree({}); // no package.json here - it("throws if no class found in source file", () => { - expect(() => identifyPackageType(noClassSource)).toThrow( - "No plugin or extension class found in source file." - ); + const { status, stdout } = runCli( + [ + "--source", + path.join(pkg, "src", "index.ts"), + "--dest", + dest, + "--package-json", + path.join(pkg, "package.json"), + "--dry-run", + ], + elsewhere, + ); + + expect(status).toBe(0); + expect(stdout).toMatch(/source\s+.*\(explicit\)/); + expect(stdout).toContain("example (none)"); + }); +}); + +describe("cli fail-fast", () => { + it("exits 1 when cwd has no package.json", () => { + const root = tree({ "src/index.ts": pluginSource }); + const { status, stderr } = runCli(["--dry-run"], root); + expect(status).toBe(1); + expect(stderr).toContain("No package.json found"); + }); + + it("exits 1 when no source can be auto-detected", () => { + const root = tree({ "package.json": pkgJson() }); + const { status, stderr } = runCli(["--dry-run"], root); + expect(status).toBe(1); + expect(stderr).toContain("Could not auto-detect a source file"); + }); + + it("exits 1 when no destination doc can be found", () => { + const root = tree({ "package.json": pkgJson(), "src/index.ts": pluginSource }); + const { status, stderr } = runCli(["--dry-run"], root); + expect(status).toBe(1); + expect(stderr).toContain("Could not find an existing docs file"); + }); +}); + +describe("cli end-to-end write", () => { + it("generates docs into the discovered destination", () => { + const root = tree({ + "package.json": pkgJson(), + "src/index.ts": pluginSource, + "docs/test.md": "", }); + + const { status } = runCli([], root); + + expect(status).toBe(0); + const written = fs.readFileSync(path.join(root, "docs", "test.md"), "utf-8"); + expect(written).toContain("\n${content}\n`; - return [section.heading, wrapped]; - }), - ); +export function getExtensionDocs( + info: ExtensionInfo, + template: SectionTemplate[] = defaultExtensionTemplate, +): Record { + return renderSections(info, template); } \ No newline at end of file diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index 59da06a..d0a1d8e 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,5 +1,5 @@ import { PluginInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, topParameterChart, topDataChart } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderSections, topParameterChart, topDataChart } from "./utils.js"; const stringifyTypeMap: Record = { "ParameterType.STRING": "string", @@ -23,7 +23,7 @@ const getTypeName = (type: string, array?: boolean): string => { return array ? `array of ${baseType}` : baseType; }; -const mainTemplate: SectionTemplate[] = [ +export const defaultPluginTemplate: SectionTemplate[] = [ { heading: "introduction", render: (info) => { @@ -85,12 +85,9 @@ ${sections} }, ]; -export function getPluginDocs(info: PluginInfo): Record { - return Object.fromEntries( - mainTemplate.map((section) => { - const content = section.render(info); - const wrapped = `\n${content}\n`; - return [section.heading, wrapped]; - }), - ); +export function getPluginDocs( + info: PluginInfo, + template: SectionTemplate[] = defaultPluginTemplate, +): Record { + return renderSections(info, template); } diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts index 43e25e8..8e149a2 100644 --- a/packages/autodoc/src/renderers/timeline.ts +++ b/packages/autodoc/src/renderers/timeline.ts @@ -1,5 +1,5 @@ import { SectionTemplate, TimelineInfo, TimelineHelperInfo, TimelineInterfaceInfo, ParameterInfo } from "../types/info.js"; -import { topParameterChart } from "./utils.js"; +import { renderSections, topParameterChart } from "./utils.js"; const getTypeName = (type: string, array?: boolean): string => (array ? `array of ${type}` : type); @@ -55,7 +55,7 @@ function renderHelperGroup(group: Record): string { return sections || "*None*"; } -const mainTemplate: SectionTemplate[] = [ +export const defaultTimelineTemplate: SectionTemplate[] = [ { heading: "introduction", render: (info) => { @@ -138,12 +138,9 @@ ${sections} }, ]; -export function getTimelineDocs(info: TimelineInfo): Record { - return Object.fromEntries( - mainTemplate.map((section) => { - const content = section.render(info); - const wrapped = `\n${content}\n`; - return [section.heading, wrapped]; - }), - ); +export function getTimelineDocs( + info: TimelineInfo, + template: SectionTemplate[] = defaultTimelineTemplate, +): Record { + return renderSections(info, template); } diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts index 5418560..c37caaa 100644 --- a/packages/autodoc/src/renderers/utils.ts +++ b/packages/autodoc/src/renderers/utils.ts @@ -1,4 +1,22 @@ -import { ParameterInfo } from "../types/info.js"; +import { ParameterInfo, SectionTemplate } from "../types/info.js"; + +/** + * renders a given template with `info`, returning a record keyed with the + * section headers, and values containing the rendered content per section. + * used in each `get*Docs` function. + */ +export function renderSections( + info: T, + template: SectionTemplate[], +): Record { + return Object.fromEntries( + template.map((section) => { + const content = section.render(info); + const wrapped = `\n${content}\n`; + return [section.heading, wrapped]; + }), + ); +} export const topParameterChart = `| Parameter | Type | Default Value | Description | | --------- | ---- | ------------- | ----------- |`; diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts index 728409b..ad21da6 100644 --- a/packages/autodoc/src/types/info.ts +++ b/packages/autodoc/src/types/info.ts @@ -61,6 +61,12 @@ export interface ExampleInfo { } export interface SectionTemplate { - heading: string; + heading: string; render: (info: T) => string; } + +export interface AutodocConfig { + plugin?: SectionTemplate[]; + extension?: SectionTemplate[]; + timeline?: SectionTemplate[]; +} diff --git a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap new file mode 100644 index 0000000..f3d32cb --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extension renderer (default template) matches the rendered snapshot 1`] = ` +{ + "data": " +## Data Generated + +| Name | Type | Value | +| ---- | ---- | ----- | +| samples | ParameterType.OBJECT | Collected samples. | +", + "examples": " +## Examples + +### Basic example (examples/basic.html) + +\`\`\`js +initJsPsych({ extensions: [...] }); +\`\`\` +", + "init-parameters": " +### Initialization Parameters +Initialization parameters are set when calling \`initJsPsych()\`. + +\`\`\`js +initJsPsych({ + extensions: { + { type: jsPsychExtensionTestExtension, params: { ... } } + } +}) +\`\`\` + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| tracking | ParameterType.BOOL | \`true\` | Whether to track. | +", + "introduction": " +# test-extension + +A test extension. + +Current version: 1.0.0 +", + "parameters": " +## Parameters + +", + "trial-parameters": " +### Trial Parameters + +Trial parameters are set when adding an extension to the trial object. + +\`\`\`js +var trial = { + type: jsPsych..., + extensions: [ + { type: jsPsychExtensionTestExtension, params: { ... } } + ] +} +\`\`\` + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| label | ParameterType.STRING | \`undefined\` | Trial label. | +", +} +`; diff --git a/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap new file mode 100644 index 0000000..af0338a --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`plugin renderer (default template) matches the rendered snapshot 1`] = ` +{ + "data": " +## Data + +In addition to the [default data collected by all plugins](https://www.jspsych.org/latest/overview/plugins#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Value | +| ---- | ---- | ----- | +| rt | integer | Response time in ms. | +| response | string | The key pressed. | +", + "examples": " +## Examples + +### Basic example (examples/basic.html) + +\`\`\`js +const trial = { type: jsPsychTestPlugin }; +\`\`\` +", + "introduction": " +# test-plugin + +A test plugin. + +Current version: 1.0.0 +", + "parameters": " +## Parameters + +In addition to the [parameters available in all plugins](https://www.jspsych.org/latest/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of \`undefined\` must be specified. Other parameters can be left unspecified if the default value is acceptable. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| stimulus | HTML string | \`undefined\` | The stimulus. | +| trials | integer | 1 | Number of trials. | +| choices | array of keys | \`"ALL_KEYS"\` | Valid keys. | +", +} +`; diff --git a/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap new file mode 100644 index 0000000..8a1536b --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`timeline renderer (default template) matches the rendered snapshot 1`] = ` +{ + "api-reference": " +## API Reference +", + "configuration-options": " +## Configuration Options + +These types are shared by multiple parameters above. + +*None* +", + "create-timeline": " +### \`createTimeline()\` + +Builds the timeline. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| stimuli | array of string[] | \`undefined\` | List of stimuli. | +", + "examples": " +## Examples + +### Basic example (examples/basic.html) + +\`\`\`js +createTimeline(jsPsych); +\`\`\` +", + "installation": " +## Installation + +TODO +", + "introduction": " +# test-timeline + +A test timeline. + +Current version: 1.0.0 +", + "timeline-units": " +### \`timelineUnits\` + +The following helper functions are exported as part of \`timelineUnits\` and can be used to build pieces of the timeline. + +#### \`trial()\` + +A single trial unit. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +*None* +", + "utils": " +### \`utils\` + +The following helper functions are exported as part of \`utils\`. + +*None* +", +} +`; diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts new file mode 100644 index 0000000..18fd32e --- /dev/null +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -0,0 +1,45 @@ +import { getExtensionDocs } from "../../src/renderers/extension.js"; +import { ExtensionInfo } from "../../src/types/info.js"; + +const info: ExtensionInfo = { + name: "test-extension", + description: "A test extension.", + version: "1.0.0", + initializeParameters: { + tracking: { type: "ParameterType.BOOL", default: "true", description: "Whether to track." }, + }, + onStartParameters: { + label: { type: "ParameterType.STRING", default: "undefined", description: "Trial label." }, + }, + onLoadParameters: {}, + onFinishParameters: {}, + data: { + samples: { type: "ParameterType.OBJECT", default: "", description: "Collected samples." }, + }, + examples: { + "Basic example": { path: "examples/basic.html", code: "initJsPsych({ extensions: [...] });" }, + }, +}; + +describe("extension renderer (default template)", () => { + const docs = getExtensionDocs(info); + + it("produces the default sections", () => { + expect(Object.keys(docs)).toEqual([ + "introduction", + "parameters", + "init-parameters", + "trial-parameters", + "data", + "examples", + ]); + }); + + it("derives the jsPsychExtension name in the usage snippets", () => { + expect(docs["init-parameters"]).toContain("jsPsychExtensionTestExtension"); + }); + + it("matches the rendered snapshot", () => { + expect(getExtensionDocs(info)).toMatchSnapshot(); + }); +}); diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index 55f6993..5df8fd9 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -1,3 +1,58 @@ -describe('plugin renderer', () => { - test.todo('renders plugin info'); +import { getPluginDocs } from "../../src/renderers/plugin.js"; +import { PluginInfo, SectionTemplate } from "../../src/types/info.js"; + +// A representative PluginInfo, built inline so these tests exercise the default +// template's rendering logic in isolation from the parser. +const info: PluginInfo = { + name: "test-plugin", + description: "A test plugin.", + version: "1.0.0", + parameters: { + stimulus: { type: "ParameterType.HTML_STRING", default: "undefined", description: "The stimulus." }, + trials: { type: "ParameterType.INT", default: "1", description: "Number of trials." }, + choices: { type: "ParameterType.KEYS", default: '"ALL_KEYS"', array: true, description: "Valid keys." }, + }, + data: { + rt: { type: "ParameterType.INT", default: "", description: "Response time in ms." }, + response: { type: "ParameterType.STRING", default: "", description: "The key pressed." }, + }, + examples: { + "Basic example": { path: "examples/basic.html", code: "const trial = { type: jsPsychTestPlugin };" }, + }, +}; + +describe("plugin renderer (default template)", () => { + const docs = getPluginDocs(info); + + it("produces the default sections", () => { + expect(Object.keys(docs)).toEqual(["introduction", "parameters", "data", "examples"]); + }); + + it("maps ParameterType values to human-readable names", () => { + expect(docs.parameters).toContain("HTML string"); + expect(docs.parameters).toContain("integer"); + expect(docs.parameters).toContain("array of keys"); + }); + + it("renders data rows and examples", () => { + expect(docs.data).toContain("Response time in ms."); + expect(docs.examples).toContain("examples/basic.html"); + }); + + it("matches the rendered snapshot", () => { + expect(getPluginDocs(info)).toMatchSnapshot(); + }); +}); + +describe("plugin renderer (custom template)", () => { + it("fully replaces the default sections", () => { + const custom: SectionTemplate[] = [ + { heading: "custom", render: (i) => `# ${i.name}` }, + ]; + const docs = getPluginDocs(info, custom); + expect(Object.keys(docs)).toEqual(["custom"]); + // none of the defaults survive + expect(docs).not.toHaveProperty("introduction"); + expect(docs).not.toHaveProperty("parameters"); + }); }); diff --git a/packages/autodoc/tests/renderers/timeline.test.ts b/packages/autodoc/tests/renderers/timeline.test.ts new file mode 100644 index 0000000..16711b6 --- /dev/null +++ b/packages/autodoc/tests/renderers/timeline.test.ts @@ -0,0 +1,47 @@ +import { getTimelineDocs } from "../../src/renderers/timeline.js"; +import { TimelineInfo } from "../../src/types/info.js"; + +const info: TimelineInfo = { + name: "test-timeline", + description: "A test timeline.", + version: "1.0.0", + createTimeline: { + description: "Builds the timeline.", + helperParameters: { + stimuli: { type: "string[]", default: "undefined", array: true, description: "List of stimuli." }, + }, + }, + timelineUnits: { + trial: { description: "A single trial unit.", helperParameters: {} }, + }, + utils: {}, + interfaces: {}, + examples: { + "Basic example": { path: "examples/basic.html", code: "createTimeline(jsPsych);" }, + }, +}; + +describe("timeline renderer (default template)", () => { + const docs = getTimelineDocs(info); + + it("produces the default sections", () => { + expect(Object.keys(docs)).toEqual([ + "introduction", + "installation", + "api-reference", + "create-timeline", + "timeline-units", + "utils", + "configuration-options", + "examples", + ]); + }); + + it("renders the createTimeline helper body", () => { + expect(docs["create-timeline"]).toContain("Builds the timeline."); + }); + + it("matches the rendered snapshot", () => { + expect(getTimelineDocs(info)).toMatchSnapshot(); + }); +}); diff --git a/packages/autodoc/tests/renderers/utils.test.ts b/packages/autodoc/tests/renderers/utils.test.ts new file mode 100644 index 0000000..e9c7a8d --- /dev/null +++ b/packages/autodoc/tests/renderers/utils.test.ts @@ -0,0 +1,58 @@ +import { renderSections } from "../../src/renderers/utils.js"; +import { SectionTemplate } from "../../src/types/info.js"; + +// `renderSections` is the shared, type-agnostic wrapper behind every `get*Docs` +// function. It does not know about any particular `*Info` shape or default +// template, so it is tested generically with a throwaway info object and a +// fake template. This is the contract that custom-template (config) users +// depend on. +interface FakeInfo { + name: string; +} + +describe("renderSections", () => { + const info: FakeInfo = { name: "x" }; + + it("keys the result by section heading, in template order", () => { + const template: SectionTemplate[] = [ + { heading: "a", render: () => "A" }, + { heading: "b", render: () => "B" }, + ]; + expect(Object.keys(renderSections(info, template))).toEqual(["a", "b"]); + }); + + it("wraps each rendered section in matching sentinel tags", () => { + const template: SectionTemplate[] = [{ heading: "intro", render: () => "hello" }]; + expect(renderSections(info, template).intro).toBe( + "\nhello\n", + ); + }); + + it("passes the exact info object to each render function", () => { + let received: FakeInfo | undefined; + renderSections(info, [ + { + heading: "a", + render: (i) => { + received = i; + return ""; + }, + }, + ]); + expect(received).toBe(info); + }); + + it("returns an empty record for an empty template", () => { + expect(renderSections(info, [])).toEqual({}); + }); + + it("keeps the last section when headings collide", () => { + const template: SectionTemplate[] = [ + { heading: "dup", render: () => "first" }, + { heading: "dup", render: () => "second" }, + ]; + const out = renderSections(info, template); + expect(Object.keys(out)).toEqual(["dup"]); + expect(out.dup).toContain("second"); + }); +}); From 47495390538f2ada55277c27c791e333167edd8c Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:39:47 -0400 Subject: [PATCH 12/26] move typescript to dependencies --- package-lock.json | 11 ++++++----- packages/autodoc/package.json | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e28172d..bb5e3ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9889,8 +9889,9 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "dev": true, + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10490,7 +10491,8 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "commander": "^14.0.3" + "commander": "^14.0.3", + "typescript": "^5.9.0" }, "bin": { "autodoc": "dist/cli.js" @@ -10499,8 +10501,7 @@ "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "jest": "^29.6.2", - "ts-jest": "^29.0.0", - "typescript": "^5.0.0" + "ts-jest": "^29.0.0" }, "engines": { "node": ">=20" diff --git a/packages/autodoc/package.json b/packages/autodoc/package.json index e1c86fe..25775d9 100644 --- a/packages/autodoc/package.json +++ b/packages/autodoc/package.json @@ -16,9 +16,10 @@ "templates" ], "scripts": { - "build": "tsc", + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc", "start": "node dist/cli.js", - "test": "node --experimental-vm-modules ../../node_modules/.bin/jest" + "test": "node --experimental-vm-modules ../../node_modules/.bin/jest", + "prepublishOnly": "npm run build" }, "keywords": [ "jspsych", @@ -29,14 +30,14 @@ "author": "jade", "license": "MIT", "dependencies": { - "commander": "^14.0.3" + "commander": "^14.0.3", + "typescript": "^5.9.0" }, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "jest": "^29.6.2", - "ts-jest": "^29.0.0", - "typescript": "^5.0.0" + "ts-jest": "^29.0.0" }, "engines": { "node": ">=20" From 0b9ab58e1681e9b4b49b96d6c39fc97b6335bffc Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 1 Jul 2026 15:00:03 -0700 Subject: [PATCH 13/26] move param type map into utils and import into renderers/parsers for use in both param and data tables --- packages/autodoc/src/parsers/utils.ts | 4 +++- packages/autodoc/src/renderers/plugin.ts | 19 ++----------------- packages/autodoc/src/renderers/utils.ts | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index cc1e378..27fecf4 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; import { ExampleInfo, ParameterInfo } from "../types/info.js"; +import { PARAMETER_TYPE_MAP } from "../renderers/utils.js"; // --- PARSE SRC FILE UTILS --- @@ -48,7 +49,8 @@ function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFil switch (key) { case "type": { if (ts.isPropertyAccessExpression(prop.initializer)) { - result.type = prop.initializer.getText(source); + const raw = prop.initializer.getText(source); + result.type = PARAMETER_TYPE_MAP[raw] ?? raw; } break; } diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index d0a1d8e..f5c2eb9 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,22 +1,7 @@ import { PluginInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, renderSections, topParameterChart, topDataChart } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; -const stringifyTypeMap: Record = { - "ParameterType.STRING": "string", - "ParameterType.INT": "integer", - "ParameterType.FLOAT": "float", - "ParameterType.BOOL": "boolean", - "ParameterType.FUNCTION": "function", - "ParameterType.KEY": "key", - "ParameterType.KEYS": "keys", - "ParameterType.SELECT": "selection", //TODO: infer type from options - "ParameterType.HTML_STRING": "HTML string", - "ParameterType.IMAGE": "image file", - "ParameterType.AUDIO": "audio file", - "ParameterType.VIDEO": "video file", - "ParameterType.OBJECT": "object", - "ParameterType.COMPLEX": "object", -}; +const stringifyTypeMap = PARAMETER_TYPE_MAP; const getTypeName = (type: string, array?: boolean): string => { const baseType = stringifyTypeMap[type] || type; diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts index c37caaa..0ebb7c3 100644 --- a/packages/autodoc/src/renderers/utils.ts +++ b/packages/autodoc/src/renderers/utils.ts @@ -18,6 +18,23 @@ export function renderSections( ); } +export const PARAMETER_TYPE_MAP: Record = { + "ParameterType.STRING": "string", + "ParameterType.INT": "integer", + "ParameterType.FLOAT": "float", + "ParameterType.BOOL": "boolean", + "ParameterType.FUNCTION": "function", + "ParameterType.KEY": "key", + "ParameterType.KEYS": "keys", + "ParameterType.SELECT": "selection", + "ParameterType.HTML_STRING": "HTML string", + "ParameterType.IMAGE": "image file", + "ParameterType.AUDIO": "audio file", + "ParameterType.VIDEO": "video file", + "ParameterType.OBJECT": "object", + "ParameterType.COMPLEX": "object", +}; + export const topParameterChart = `| Parameter | Type | Default Value | Description | | --------- | ---- | ------------- | ----------- |`; From 198deece2ae8241dc142842897d8bea365685e49 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 1 Jul 2026 15:36:10 -0700 Subject: [PATCH 14/26] fix expected param type strings in data extraction tests --- packages/autodoc/tests/parsers/extension.test.ts | 6 +++--- packages/autodoc/tests/parsers/plugin.test.ts | 8 ++++---- packages/autodoc/tests/renderers/extension.test.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index de0ea77..2230122 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -78,16 +78,16 @@ describe('getExtensionInfo', () => { it('extracts data parameters', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); - expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); + expect(info.data.data_param.type).toBe('float'); expect(info.data.data_param.description).toBe('Data parameter description.'); - expect(info.data.double_data.type).toBe('ParameterType.BOOL'); + expect(info.data.double_data.type).toBe('boolean'); expect(info.data.double_data.description).toBe('Multi-line data parameter description. It has two lines for data.'); }); it('extracts nested data parameters', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); - expect(info.data.grid.type).toBe('ParameterType.COMPLEX'); + expect(info.data.grid.type).toBe('object'); expect(info.data.grid.description).toBe("Now let's have a grid."); expect(info.data.grid.nested).toBeDefined(); }); diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index f0d1007..5cc95e0 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -30,9 +30,9 @@ describe('getPluginInfo', () => { it('extracts parameters with types and descriptions', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); - expect(info.parameters.single.type).toBe('ParameterType.STRING'); + expect(info.parameters.single.type).toBe('string'); expect(info.parameters.single.description).toBe('Single-line description.'); - expect(info.parameters.double_double.type).toBe('ParameterType.INT'); + expect(info.parameters.double_double.type).toBe('integer'); expect(info.parameters.double_double.description).toBe('Multi-line description. It has two lines for a parameter.'); }); @@ -45,9 +45,9 @@ describe('getPluginInfo', () => { it('extracts data parameters', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); - expect(info.data.data_param.type).toBe('ParameterType.FLOAT'); + expect(info.data.data_param.type).toBe('float'); expect(info.data.data_param.description).toBe('Data parameter description.'); - expect(info.data.double_data.type).toBe('ParameterType.BOOL'); + expect(info.data.double_data.type).toBe('boolean'); expect(info.data.double_data.description).toBe('Multi-line data parameter description. It has two lines for data.'); }); }); diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index 18fd32e..2aa88ef 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -6,15 +6,15 @@ const info: ExtensionInfo = { description: "A test extension.", version: "1.0.0", initializeParameters: { - tracking: { type: "ParameterType.BOOL", default: "true", description: "Whether to track." }, + tracking: { type: "boolean", default: "true", description: "Whether to track." }, }, onStartParameters: { - label: { type: "ParameterType.STRING", default: "undefined", description: "Trial label." }, + label: { type: "string", default: "undefined", description: "Trial label." }, }, onLoadParameters: {}, onFinishParameters: {}, data: { - samples: { type: "ParameterType.OBJECT", default: "", description: "Collected samples." }, + samples: { type: "object", default: "", description: "Collected samples." }, }, examples: { "Basic example": { path: "examples/basic.html", code: "initJsPsych({ extensions: [...] });" }, From f179972f6e4bbedcb7f9dd3298424f235bf8abe0 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 1 Jul 2026 15:38:32 -0700 Subject: [PATCH 15/26] add tests for parameter type map and data generated type values in plugin/extension --- .../autodoc/tests/renderers/extension.test.ts | 8 ++++++++ packages/autodoc/tests/renderers/plugin.test.ts | 6 ++++++ packages/autodoc/tests/renderers/utils.test.ts | 17 ++++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index 2aa88ef..cebf506 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -39,6 +39,14 @@ describe("extension renderer (default template)", () => { expect(docs["init-parameters"]).toContain("jsPsychExtensionTestExtension"); }); + it("renders human-readable type strings in parameter and data tables", () => { + expect(docs["init-parameters"]).toContain("boolean"); + expect(docs["trial-parameters"]).toContain("string"); + expect(docs["data"]).toContain("object"); + expect(docs["init-parameters"]).not.toContain("ParameterType"); + expect(docs["data"]).not.toContain("ParameterType"); + }); + it("matches the rendered snapshot", () => { expect(getExtensionDocs(info)).toMatchSnapshot(); }); diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index 5df8fd9..f4dc526 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -39,6 +39,12 @@ describe("plugin renderer (default template)", () => { expect(docs.examples).toContain("examples/basic.html"); }); + it("maps ParameterType values to human-readable strings in the data table", () => { + expect(docs.data).toContain("integer"); + expect(docs.data).toContain("string"); + expect(docs.data).not.toContain("ParameterType"); + }); + it("matches the rendered snapshot", () => { expect(getPluginDocs(info)).toMatchSnapshot(); }); diff --git a/packages/autodoc/tests/renderers/utils.test.ts b/packages/autodoc/tests/renderers/utils.test.ts index e9c7a8d..f60f776 100644 --- a/packages/autodoc/tests/renderers/utils.test.ts +++ b/packages/autodoc/tests/renderers/utils.test.ts @@ -1,6 +1,21 @@ -import { renderSections } from "../../src/renderers/utils.js"; +import { renderSections, PARAMETER_TYPE_MAP } from "../../src/renderers/utils.js"; import { SectionTemplate } from "../../src/types/info.js"; +describe("PARAMETER_TYPE_MAP", () => { + it("maps common ParameterType values to human-readable strings", () => { + expect(PARAMETER_TYPE_MAP["ParameterType.BOOL"]).toBe("boolean"); + expect(PARAMETER_TYPE_MAP["ParameterType.STRING"]).toBe("string"); + expect(PARAMETER_TYPE_MAP["ParameterType.INT"]).toBe("integer"); + expect(PARAMETER_TYPE_MAP["ParameterType.FLOAT"]).toBe("float"); + expect(PARAMETER_TYPE_MAP["ParameterType.HTML_STRING"]).toBe("HTML string"); + expect(PARAMETER_TYPE_MAP["ParameterType.COMPLEX"]).toBe("object"); + }); + + it("returns undefined for unknown ParameterType values", () => { + expect(PARAMETER_TYPE_MAP["ParameterType.UNKNOWN"]).toBeUndefined(); + }); +}); + // `renderSections` is the shared, type-agnostic wrapper behind every `get*Docs` // function. It does not know about any particular `*Info` shape or default // template, so it is tested generically with a throwaway info object and a From a06a1d58cc4faa9ec98f6f2ae281087f9b68ab35 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 1 Jul 2026 15:40:44 -0700 Subject: [PATCH 16/26] update extension test snapshot with string versions of param types --- .../tests/renderers/__snapshots__/extension.test.ts.snap | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap index f3d32cb..ebef847 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap @@ -7,7 +7,7 @@ exports[`extension renderer (default template) matches the rendered snapshot 1`] | Name | Type | Value | | ---- | ---- | ----- | -| samples | ParameterType.OBJECT | Collected samples. | +| samples | object | Collected samples. | ", "examples": " ## Examples @@ -32,7 +32,7 @@ initJsPsych({ | Parameter | Type | Default Value | Description | | --------- | ---- | ------------- | ----------- | -| tracking | ParameterType.BOOL | \`true\` | Whether to track. | +| tracking | boolean | \`true\` | Whether to track. | ", "introduction": " # test-extension @@ -61,7 +61,7 @@ var trial = { | Parameter | Type | Default Value | Description | | --------- | ---- | ------------- | ----------- | -| label | ParameterType.STRING | \`undefined\` | Trial label. | +| label | string | \`undefined\` | Trial label. | ", } `; From 7cd82a6fac11fd1f0ad2d494355627ac43479fc2 Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:03:39 -0400 Subject: [PATCH 17/26] add testing workflow and ensure `npm t` is runnable in root directory --- .github/workflows/build.yml | 32 ++++++++++++++++++++++++++++++++ package.json | 5 +++-- packages/autodoc/jest.config.js | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c96006c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: build + +on: + push: + pull_request: + types: [opened, reopened, ready_for_review, review_requested, synchronize] + +jobs: + test: + name: Build, lint, and test on Node.js ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build packages + run: npm run build --workspaces --if-present + + - name: Run tests + run: npm run test -- --ci --maxWorkers=2 --reporters=default --reporters=github-actions diff --git a/package.json b/package.json index b29ca46..1595136 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "prepare": "husky install", "changeset": "changeset", "changeset:version": "changeset version", - "changeset:publish": "changeset publish" + "changeset:publish": "changeset publish", + "test": "node --experimental-vm-modules node_modules/.bin/jest" }, "dependencies": {}, "devDependencies": { @@ -35,7 +36,7 @@ }, "jest": { "projects": [ - "/packages/*" + "/packages/*/jest.config.js" ] }, "name": "", diff --git a/packages/autodoc/jest.config.js b/packages/autodoc/jest.config.js index a10c9f6..4c018b1 100644 --- a/packages/autodoc/jest.config.js +++ b/packages/autodoc/jest.config.js @@ -8,6 +8,6 @@ export default { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: './tests/tsconfig.json' }], + '^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: '/tests/tsconfig.json' }], }, }; From 4f38d7fb71f7b5e1b60da3ad61fcb460f2d8668c Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:51:46 -0700 Subject: [PATCH 18/26] extension example inference: detect relevant code by var name or extensions property, detect extensions in initJsPsych and produce error when extension is registered but never used in a trial --- packages/autodoc/src/parsers/extension.ts | 33 +++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 4e286db..fc3c553 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -97,8 +97,24 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; let initStatement: ts.VariableStatement | undefined; let initJsPsychVarName: string | undefined; + let initJsPsychHasExtensions = false; const trialNodes: ts.VariableDeclaration[] = []; + function hasExtensionsProperty(node: ts.Node): boolean { + if (ts.isCallExpression(node)) return false; + if ( + ts.isObjectLiteralExpression(node) && + node.properties.some( + (p) => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === "extensions", + ) + ) + return true; + return ts.forEachChild(node, hasExtensionsProperty) ?? false; + } + function visitNodes(node: ts.Node) { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { const init = node.initializer; @@ -113,17 +129,10 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (ts.isVariableStatement(stmt)) { initStatement = stmt; initJsPsychVarName = node.name.text; + initJsPsychHasExtensions = init.arguments.some(hasExtensionsProperty); } - } - - if (trialPattern.test(node.name.text) && init && ts.isObjectLiteralExpression(init)) { - const hasExtensions = init.properties.some( - (p) => - ts.isPropertyAssignment(p) && - ts.isIdentifier(p.name) && - p.name.text === "extensions", - ); - if (hasExtensions) trialNodes.push(node); + } else if (init && (trialPattern.test(node.name.text) || hasExtensionsProperty(init))) { + trialNodes.push(node); } } ts.forEachChild(node, visitNodes); @@ -137,7 +146,9 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (trialNodes.length === 0) throw new Error( - `${sourcePath}: no trial variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, + initJsPsychHasExtensions + ? `${sourcePath}: extension found in initJsPsych but no trial variables found that use the extension — use jspsych-autodoc:start/end sentinels instead` + : `${sourcePath}: no variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, ); // build map of all local variable declarations, excluding trial nodes themselves From 1de842cf8208b1187c210b4a57d4f0d9e5b8b581 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:52:48 -0700 Subject: [PATCH 19/26] update inferCodeBlock JSDoc comment to reflect new logic (either trial var name or extensions property) --- packages/autodoc/src/parsers/extension.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index fc3c553..ed63b3e 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -75,9 +75,10 @@ export function getExtensionInfo( /** * Fallback code block extractor for HTML extension example files without sentinels. Requires * exactly one inline script block. Extracts the `initJsPsych` call and all trial variables - * (camel/snake case) whose object literal contains an `extensions` field. For each matched - * trial, its direct local dependencies (one level of indirection) are also included. - * The initJsPsych variable itself is never treated as a dependency. + * (any variable name that matches the "trial" pattern in camel/snake case) OR whose object + * literal contains an `extensions` field. For each matched trial, its direct local + * dependencies (one level of indirection) are also included. The initJsPsych variable itself + * is never treated as a dependency. */ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; From 94a2991a0b5be253605d2861af3f6ee8b921a77b Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:54:49 -0700 Subject: [PATCH 20/26] add tests for extension example code parsing and infer-tests directory with more test example files --- .../infer-tests/extension-in-init-only.html | 22 +++++ .../infer-tests/mixed-detection.html | 24 ++++++ .../no-trial-name-or-extension.html | 18 ++++ .../non-trial-name-with-extension.html | 21 +++++ .../infer-tests/trial-name-no-extension.html | 20 +++++ .../infer-tests/trial-name-non-object.html | 15 ++++ .../trial-name-with-extension.html | 21 +++++ .../autodoc/tests/parsers/extension.test.ts | 84 +++++++++++++++++++ 8 files changed, 225 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html new file mode 100644 index 0000000..52b5320 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html @@ -0,0 +1,22 @@ + + + + init only extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html new file mode 100644 index 0000000..d544400 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html @@ -0,0 +1,24 @@ + + + + mixed detection example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html new file mode 100644 index 0000000..573ad97 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html @@ -0,0 +1,18 @@ + + + + no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html new file mode 100644 index 0000000..bf221a6 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + non trial name example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html new file mode 100644 index 0000000..0e1c836 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html @@ -0,0 +1,20 @@ + + + + trial name no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html new file mode 100644 index 0000000..7a213c0 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html @@ -0,0 +1,15 @@ + + + + trial name non object example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html new file mode 100644 index 0000000..1a6c2a1 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + trial name example + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 2230122..6916dd4 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -93,6 +93,90 @@ describe('getExtensionInfo', () => { }); }); +describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => { + const inferTestsDir = path.resolve(__dirname, '../fixtures/extension/infer-tests'); + let classNode: ts.ClassDeclaration; + + beforeAll(() => { + classNode = identifyPackageType(fixtureSource).mainNode as ts.ClassDeclaration; + }); + + it('throws when no extension is found anywhere and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'no-trial-name-or-extension.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('no variables with an "extensions" field found'); + }); + + it('throws when extension is found only in initJsPsych and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'extension-in-init-only.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('extension found in initJsPsych but no trial variables found that use the extension'); + }); + + it('succeeds when trial object has extension and name does not match trial name regex', () => { + const filePath = path.join(inferTestsDir, 'non-trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['non trial name example']).toBeDefined(); + const code = info.examples['non trial name example'].code; + expect(code).toContain('myBlock'); + expect(code).toContain('initJsPsych'); + }); + + it('succeeds when trial object has extension and uses trial name regex and does not duplicate', () => { + const filePath = path.join(inferTestsDir, 'trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name example']).toBeDefined(); + const code = info.examples['trial name example'].code; + expect(code).toContain('const trial'); + expect((code.match(/const trial\b/g) ?? []).length).toBe(1); + }); + + it('succeeds when trial name matches regex but trial object has no extensions property', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name no extension example']).toBeDefined(); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('initJsPsych'); + }); + + it('collects local dependencies of a trial found by name (no extensions property)', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const stimulus'); + expect(code.indexOf('const stimulus')).toBeLessThan(code.indexOf('const trial')); + }); + + it('includes variables detected by both name and extensions in the same example', () => { + const filePath = path.join(inferTestsDir, 'mixed-detection.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['mixed detection example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('const myBlock'); + }); + + it('includes a trial-named variable even when its initializer is not an object', () => { + const filePath = path.join(inferTestsDir, 'trial-name-non-object.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['trial name non object example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('"experiment stimulus text"'); + }); + + it('does not throw when start/end sentinels are used even if inferCodeBlock would fail', () => { + const filePath = path.join(inferTestsDir, 'sentinel-bypass.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['sentinel bypass example']).toBeDefined(); + }); +}); + describe('getExtensionInfoAndExamples', () => { const examplesDir = path.resolve(__dirname, '../fixtures/extension/examples'); From 5dd528a355b3b9f01ac8d83461972a460582ba57 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 17:00:18 -0700 Subject: [PATCH 21/26] add missing test example file --- .../infer-tests/sentinel-bypass.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html new file mode 100644 index 0000000..54093a4 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html @@ -0,0 +1,20 @@ + + + + + + + + + From ce22b8f43a5fd7696bdf7d79dffe67f4b373aeaf Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:16:16 -0400 Subject: [PATCH 22/26] add static/instance method detection to plugin and extension autodoc --- packages/autodoc/src/parsers/extension.ts | 13 +- packages/autodoc/src/parsers/plugin.ts | 12 +- packages/autodoc/src/parsers/timeline.ts | 486 +------------- packages/autodoc/src/parsers/utils.ts | 616 +++++++++++++++++- packages/autodoc/src/renderers/extension.ts | 13 +- packages/autodoc/src/renderers/plugin.ts | 13 +- packages/autodoc/src/renderers/utils.ts | 43 +- packages/autodoc/src/types/info.ts | 22 +- .../autodoc/tests/fixtures/extension/basic.ts | 23 + .../autodoc/tests/fixtures/plugin/basic.ts | 40 ++ .../autodoc/tests/parsers/extension.test.ts | 30 + packages/autodoc/tests/parsers/plugin.test.ts | 49 ++ .../__snapshots__/extension.test.ts.snap | 9 + .../__snapshots__/plugin.test.ts.snap | 20 + .../autodoc/tests/renderers/extension.test.ts | 15 + .../autodoc/tests/renderers/plugin.test.ts | 21 +- 16 files changed, 946 insertions(+), 479 deletions(-) diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index ed63b3e..beec7f8 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -1,6 +1,14 @@ import ts from "typescript"; import { ExtensionInfo } from "../types/info.js"; -import { collectExamples, dedent, extractJsDocComment, parseParamGroup, parseTSParamGroup } from "./utils.js"; +import { + EXTENSION_LIFECYCLE_METHODS, + collectClassFunctions, + collectExamples, + dedent, + extractJsDocComment, + parseParamGroup, + parseTSParamGroup, +} from "./utils.js"; export function getExtensionInfo( source: ts.SourceFile, @@ -15,6 +23,7 @@ export function getExtensionInfo( onLoadParameters: {}, onFinishParameters: {}, data: {}, + functions: {}, examples: {}, }; @@ -69,6 +78,8 @@ export function getExtensionInfo( } } + result.functions = collectClassFunctions(classNode, source, EXTENSION_LIFECYCLE_METHODS); + return result; } diff --git a/packages/autodoc/src/parsers/plugin.ts b/packages/autodoc/src/parsers/plugin.ts index 0d5fd17..8f5e329 100644 --- a/packages/autodoc/src/parsers/plugin.ts +++ b/packages/autodoc/src/parsers/plugin.ts @@ -1,6 +1,13 @@ import ts from "typescript"; import { PluginInfo } from "../types/info.js"; -import { collectExamples, dedent, extractJsDocComment, parseParamGroup } from "./utils.js"; +import { + PLUGIN_LIFECYCLE_METHODS, + collectClassFunctions, + collectExamples, + dedent, + extractJsDocComment, + parseParamGroup, +} from "./utils.js"; /** * Extracts plugin information from a TypeScript AST. Source must already be @@ -19,6 +26,7 @@ export function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclarat version: "", parameters: {}, data: {}, + functions: {}, examples: {}, }; @@ -64,6 +72,8 @@ export function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclarat result.data = parseParamGroup(dataProp.initializer, source); } + result.functions = collectClassFunctions(classNode, source, PLUGIN_LIFECYCLE_METHODS); + return result; } diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts index 56504e5..f088228 100644 --- a/packages/autodoc/src/parsers/timeline.ts +++ b/packages/autodoc/src/parsers/timeline.ts @@ -1,476 +1,20 @@ import ts from "typescript"; import { TimelineInfo, TimelineHelperInfo, ParameterInfo } from "../types/info.js"; -import { collectExamples, dedent, extractJsDocComment, printer } from "./utils.js"; - - -// --- INTERFACE MAP --- - -/** pairs together an interface declaration with the source file it was found in */ -type InterfaceEntry = { kind: "interface"; decl: ts.InterfaceDeclaration; source: ts.SourceFile }; - -/** - * pairs together a `const x = { ... }` object value with the source file it was - * found in, so that `typeof x` parameter types can be expanded and hoisted the - * same way interfaces are (e.g. BART's `text_object: typeof trial_text`). - */ -type ValueShapeEntry = { - kind: "value"; - objLiteral: ts.ObjectLiteralExpression; - decl: ts.VariableDeclaration; - source: ts.SourceFile; -}; - -/** anything that can be hoisted into the shared Configuration Options section */ -type HoistEntry = InterfaceEntry | ValueShapeEntry; - -/** - * the hoist key for a parsed type: an interface uses its own name, while a - * `typeof x` value-shape uses the bare value name `x` (so the shared section is - * titled `x`, not `typeof x`). Kept in sync with the `typeof ` prefix that - * `parseTypeNode` writes for value-shape types. - */ -function hoistKeyForType(type: string): string { - return type.startsWith("typeof ") ? type.slice("typeof ".length) : type; -} - -/** - * pairs together names of interface with their corresponding `ParameterInfo`s. - * makes it so we can hoist interfaces in one pass - */ -type UsageMap = Map; - -function recordInterfaceUsage( - usageMap: UsageMap, - interfaceMap: Map, - info: ParameterInfo, -): void { - const key = hoistKeyForType(info.type); - if (!info.nested || !interfaceMap.has(key)) return; - const sites = usageMap.get(key); - if (sites) sites.push(info); - else usageMap.set(key, [info]); -} - -function buildInterfaceMap( - source: ts.SourceFile, - program?: ts.Program, -): Map { - const map = new Map(); - const filesToSearch = program - ? [ - source, - ...program - .getSourceFiles() - .filter((f) => f !== source && !f.fileName.includes("/node_modules/")), - ] - : [source]; - for (const file of filesToSearch) { - for (const stmt of file.statements) { - if (ts.isInterfaceDeclaration(stmt) && !map.has(stmt.name.text)) { - map.set(stmt.name.text, { kind: "interface", decl: stmt, source: file }); - } - } - } - // second pass: object-valued consts, used to expand `typeof x` parameter types. - // interfaces win on name collisions (added first, and not overwritten here). - for (const file of filesToSearch) { - for (const stmt of file.statements) { - if (!ts.isVariableStatement(stmt)) continue; - for (const decl of stmt.declarationList.declarations) { - if ( - ts.isIdentifier(decl.name) && - decl.initializer && - ts.isObjectLiteralExpression(decl.initializer) && - !map.has(decl.name.text) - ) { - map.set(decl.name.text, { - kind: "value", - objLiteral: decl.initializer, - decl, - source: file, - }); - } - } - } - } - return map; -} - -// --- DEFAULT RESOLUTION --- - -function resolveDefaultExpr( - initializer: ts.Expression, - source: ts.SourceFile, -): ts.Expression { - if (ts.isIdentifier(initializer)) { - for (const stmt of source.statements) { - if (!ts.isVariableStatement(stmt)) continue; - for (const decl of stmt.declarationList.declarations) { - if ( - ts.isIdentifier(decl.name) && - decl.name.text === initializer.text && - decl.initializer - ) { - return decl.initializer; - } - } - } - } - return initializer; -} - -function getPropertyExpression( - objLiteral: ts.ObjectLiteralExpression, - propName: string, -): ts.Expression | undefined { - for (const prop of objLiteral.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - let name: string; - if (ts.isIdentifier(prop.name)) name = prop.name.text; - else if (ts.isStringLiteral(prop.name)) name = prop.name.text; - else continue; - if (name === propName) return prop.initializer; - } - return undefined; -} - -// --- JSDOC --- - -/** gathers jsdoc \@param tags to attach to regular params */ -function getJsDocParamDescriptions( - funcNode: ts.FunctionDeclaration, - source: ts.SourceFile, -): Record { - const result: Record = {}; - for (const tag of ts.getJSDocTags(funcNode)) { - if (!ts.isJSDocParameterTag(tag) || !ts.isIdentifier(tag.name)) continue; - const raw = tag.comment; - const desc = ( - typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join("") - )?.trim(); - if (desc) result[tag.name.text] = desc; - } - return result; -} - -// --- TYPE PARSING --- - -function entityNameText(name: ts.EntityName): string { - if (ts.isIdentifier(name)) return name.text; - return entityNameText(name.left) + "." + name.right.text; -} - -// typeSource: the source file where typeNode is defined. -// defaultSource: the source file where defaultExpr is defined (always the main source file). -// These are tracked explicitly because ts.createProgram does not set parent nodes, -// so node.getSourceFile() and node.getText() without arguments are unavailable. -function parseTypeNode( - typeNode: ts.TypeNode | undefined, - typeSource: ts.SourceFile, - defaultExpr: ts.Expression | undefined, - defaultSource: ts.SourceFile, - interfaceMap: Map, - visited: Set, - usageMap: UsageMap, -): ParameterInfo { - const info: Partial = {}; - if (defaultExpr) info.default = defaultExpr.getText(defaultSource); - - if (!typeNode) { - info.type = "unknown"; - return info as ParameterInfo; - } - - if (ts.isArrayTypeNode(typeNode)) { - info.array = true; - const inner = parseTypeNode(typeNode.elementType, typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); - info.type = inner.type; - if (inner.nested) info.nested = inner.nested; - return info as ParameterInfo; - } - - if (ts.isTypeReferenceNode(typeNode)) { - const typeName = entityNameText(typeNode.typeName); - - if (typeName === "Array" && typeNode.typeArguments?.length === 1) { - info.array = true; - const inner = parseTypeNode(typeNode.typeArguments[0], typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); - info.type = inner.type; - if (inner.nested) info.nested = inner.nested; - return info as ParameterInfo; - } - - info.type = typeName; - - if (!visited.has(typeName)) { - const entry = interfaceMap.get(typeName); - if (entry?.kind === "interface") { - const defaultObjLiteral = - defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; - info.nested = parseInterfaceMembers( - entry, - defaultObjLiteral, - defaultSource, - interfaceMap, - new Set(visited).add(typeName), - usageMap, - ); - } - } - - return info as ParameterInfo; - } - - // `typeof x`: keep the readable `typeof x` label, but if `x` resolves to a known - // object-valued const, expand its shape so the type can be hoisted (and so a - // single use still renders its fields inline). - if (ts.isTypeQueryNode(typeNode)) { - const valueName = entityNameText(typeNode.exprName); - info.type = `typeof ${valueName}`; - if (!visited.has(valueName)) { - const entry = interfaceMap.get(valueName); - if (entry?.kind === "value") { - info.nested = parseValueShapeMembers(entry.objLiteral, entry.source); - } - } - return info as ParameterInfo; - } - - if (ts.isTypeLiteralNode(typeNode)) { - info.type = printer - .printNode(ts.EmitHint.Unspecified, typeNode, typeSource) - .replace(/\s*\n\s*/g, " ") - .replace(/; \}/g, " }") - .replace(/; /g, ", ") - .trim(); - const defaultObjLiteral = - defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; - info.nested = parseTypeLiteralMembers(typeNode, typeSource, defaultObjLiteral, defaultSource, interfaceMap, visited, usageMap); - return info as ParameterInfo; - } - - info.type = printer.printNode(ts.EmitHint.Unspecified, typeNode, typeSource).trim(); - return info as ParameterInfo; -} - -function parseInterfaceMembers( - entry: InterfaceEntry, - defaultObjLiteral: ts.ObjectLiteralExpression | undefined, - defaultSource: ts.SourceFile, - interfaceMap: Map, - visited: Set, - usageMap: UsageMap, -): Record { - const result: Record = {}; - const { decl: interfaceDecl, source: memberSource } = entry; - for (const member of interfaceDecl.members) { - if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; - const name = member.name.text; - const memberDefault = defaultObjLiteral - ? getPropertyExpression(defaultObjLiteral, name) - : undefined; - const info = parseTypeNode(member.type, memberSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); - const desc = extractJsDocComment(member, memberSource); - if (desc) info.description = desc; - if (!info.default) { - const defaultTag = ts.getJSDocTags(member).find((t) => t.tagName.text === "default"); - if (defaultTag) { - const raw = defaultTag.comment; - const val = ( - typeof raw === "string" ? raw : raw?.map((n) => n.getText(memberSource)).join("") - )?.trim(); - if (val) info.default = val; - } - } - // intentionally not recorded in usageMap: hoisting only applies to types used directly - // as a function parameter (see parseFunctionParams), not to types nested inside another - // interface's members -- otherwise an interface nested inside an already-hoisted interface - // would appear to have 2+ usages (once per re-expansion of the outer interface) and get - // spuriously hoisted into its own orphaned, duplicated section. - result[name] = info; - } - return result; -} - -function parseTypeLiteralMembers( - typeNode: ts.TypeLiteralNode, - typeSource: ts.SourceFile, - defaultObjLiteral: ts.ObjectLiteralExpression | undefined, - defaultSource: ts.SourceFile, - interfaceMap: Map, - visited: Set, - usageMap: UsageMap, -): Record { - const result: Record = {}; - for (const member of typeNode.members) { - if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; - const name = member.name.text; - const memberDefault = defaultObjLiteral - ? getPropertyExpression(defaultObjLiteral, name) - : undefined; - const info = parseTypeNode(member.type, typeSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); - const desc = extractJsDocComment(member, typeSource); - if (desc) info.description = desc; - // see comment in parseInterfaceMembers: nested member usage is intentionally not recorded - result[name] = info; - } - return result; -} - -// --- VALUE SHAPE PARSING (for `typeof x`) --- - -/** - * Infers a parameter type string from a value expression (an object-const's - * property), since these are plain values with no type annotations. Resolves a - * single level of identifier reference (e.g. `instruction_pages` -> its array) - * and array element types one level deep. - */ -function inferValueType(expr: ts.Expression, source: ts.SourceFile, depth = 0): { type: string; array?: boolean } { - if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr) || ts.isTemplateExpression(expr)) - return { type: "string" }; - if (ts.isNumericLiteral(expr)) return { type: "number" }; - if (expr.kind === ts.SyntaxKind.TrueKeyword || expr.kind === ts.SyntaxKind.FalseKeyword) - return { type: "boolean" }; - if (ts.isPrefixUnaryExpression(expr)) return inferValueType(expr.operand, source, depth); - if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) return { type: "function" }; - if (ts.isArrayLiteralExpression(expr)) { - const first = expr.elements[0]; - const inner = first && depth < 3 ? inferValueType(first, source, depth + 1) : { type: "unknown" }; - return { type: inner.type, array: true }; - } - if (ts.isObjectLiteralExpression(expr)) return { type: "object" }; - if (ts.isIdentifier(expr) && depth < 1) { - const resolved = resolveDefaultExpr(expr, source); - if (resolved !== expr) return inferValueType(resolved, source, depth + 1); - } - return { type: "unknown" }; -} - -/** - * Parses an object-const's literal into `ParameterInfo`s for a `typeof x` type. - * Field types are inferred from the values, and each value's source text is kept - * as the "default" (these values are the defaults a researcher would override). - */ -function parseValueShapeMembers( - objLiteral: ts.ObjectLiteralExpression, - source: ts.SourceFile, -): Record { - const result: Record = {}; - for (const prop of objLiteral.properties) { - let name: string | undefined; - let valueExpr: ts.Expression | undefined; - if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { - name = prop.name.text; - valueExpr = prop.initializer; - } else if (ts.isShorthandPropertyAssignment(prop)) { - name = prop.name.text; - valueExpr = prop.name; - } else if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name)) { - name = prop.name.text; - } - if (!name) continue; - - const info = { type: "function" } as ParameterInfo; - if (valueExpr) { - const inferred = inferValueType(valueExpr, source); - info.type = inferred.type; - info.default = valueExpr.getText(source); - if (inferred.array) info.array = true; - } - const desc = extractJsDocComment(prop, source); - if (desc) info.description = desc; - result[name] = info; - } - return result; -} +import { + HoistEntry, + UsageMap, + buildInterfaceMap, + collectExamples, + dedent, + extractJsDocComment, + hoistKeyForType, + parseFunctionParams, + parseInterfaceMembers, + parseValueShapeMembers, +} from "./utils.js"; // --- FUNCTION PARSING --- -/** - * Picks a display name (and JSDoc description) for a destructured parameter, - * which has no identifier of its own in source. Timeline factories conventionally - * document the bag with a single `@param config ...` tag, so we adopt the first - * such "leftover" tag -- one that doesn't name an identifier parameter -- and fall - * back to `config` (suffixed on collision) when there is none. - */ -function resolveDestructuredName( - paramDescs: Record, - identifierParamNames: Set, - existing: Record, -): { name: string; description?: string } { - for (const [tagName, desc] of Object.entries(paramDescs)) { - if (!identifierParamNames.has(tagName) && !(tagName in existing)) { - return { name: tagName, description: desc }; - } - } - let name = "config"; - for (let i = 2; name in existing; i++) name = `config${i}`; - return { name }; -} - -/** - * handles destructured object parameters (like `{ ... }: Config = {}`) as a - * single parameter typed by annotations. per-field defaults are on binding elements, - * not in the object literal. this also allows for shared config interfaces to be hoisted - */ -function parseDestructuredParam( - param: ts.ParameterDeclaration, - pattern: ts.ObjectBindingPattern, - source: ts.SourceFile, - interfaceMap: Map, - usageMap: UsageMap, -): ParameterInfo { - const defaultExpr = - param.initializer && ts.isObjectLiteralExpression(param.initializer) - ? param.initializer - : undefined; - const info = parseTypeNode(param.type, source, defaultExpr, source, interfaceMap, new Set(), usageMap); - - if (info.nested) { - for (const element of pattern.elements) { - if (!ts.isIdentifier(element.name) || !element.initializer) continue; - const target = info.nested[element.name.text]; - if (target && !target.default) target.default = element.initializer.getText(source); - } - } - return info; -} - -function parseFunctionParams( - funcNode: ts.FunctionDeclaration, - source: ts.SourceFile, - interfaceMap: Map, - usageMap: UsageMap, -): Record { - const result: Record = {}; - const params = [...funcNode.parameters]; - const paramDescs = getJsDocParamDescriptions(funcNode, source); - const identifierParamNames = new Set( - params.filter((p) => ts.isIdentifier(p.name)).map((p) => (p.name as ts.Identifier).text), - ); - for (const param of params) { - if (ts.isIdentifier(param.name)) { - const name = param.name.text; - const defaultExpr = param.initializer - ? resolveDefaultExpr(param.initializer, source) - : undefined; - const info = parseTypeNode(param.type, source, defaultExpr, source, interfaceMap, new Set(), usageMap); - const desc = paramDescs[name]; - if (desc) info.description = desc; - recordInterfaceUsage(usageMap, interfaceMap, info); - result[name] = info; - } else if (ts.isObjectBindingPattern(param.name)) { - const info = parseDestructuredParam(param, param.name, source, interfaceMap, usageMap); - const { name, description } = resolveDestructuredName(paramDescs, identifierParamNames, result); - if (description && !info.description) info.description = description; - recordInterfaceUsage(usageMap, interfaceMap, info); - result[name] = info; - } - // array binding patterns and other parameter shapes are intentionally skipped - } - return result; -} - function parseHelperFunction( funcNode: ts.FunctionDeclaration, source: ts.SourceFile, @@ -479,7 +23,7 @@ function parseHelperFunction( ): TimelineHelperInfo { return { description: extractJsDocComment(funcNode, source) ?? "", - helperParameters: parseFunctionParams(funcNode, source, interfaceMap, usageMap), + helperParameters: parseFunctionParams(funcNode, funcNode, source, interfaceMap, usageMap), }; } @@ -633,9 +177,9 @@ export function getTimelineInfo(filePath: string): TimelineInfo { } /** - * generates a `inferCodeBlock` function with existing `TimelineInfo`, used to + * generates a `inferCodeBlock` function with existing `TimelineInfo`, used to * find all usages of `.createTimeline(...)`, alongside any exported - * functions from `timelineUnits` and `utils`. does one level of indirection, + * functions from `timelineUnits` and `utils`. does one level of indirection, * and deduplicates in case something like a `utils` function is found stuck in * a config file. */ diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 27fecf4..180ddc5 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; -import { ExampleInfo, ParameterInfo } from "../types/info.js"; +import { ExampleInfo, FunctionInfo, ParameterInfo, ReturnInfo } from "../types/info.js"; import { PARAMETER_TYPE_MAP } from "../renderers/utils.js"; // --- PARSE SRC FILE UTILS --- @@ -280,4 +280,618 @@ export function collectExamples( if (exampleInfo) Object.assign(result, exampleInfo); } return result; +} + +// --- SHARED FUNCTION / TYPE PARSING --- +// Turns function signatures and their TSDoc into `ParameterInfo`/`FunctionInfo`. +// Shared by the timeline parser (free `createTimeline`/`timelineUnits`/`utils` +// functions) and the plugin/extension parsers (class helper methods). + +/** pairs together an interface declaration with the source file it was found in */ +export type InterfaceEntry = { kind: "interface"; decl: ts.InterfaceDeclaration; source: ts.SourceFile }; + +/** + * pairs together a `const x = { ... }` object value with the source file it was + * found in, so that `typeof x` parameter types can be expanded and hoisted the + * same way interfaces are (e.g. BART's `text_object: typeof trial_text`). + */ +export type ValueShapeEntry = { + kind: "value"; + objLiteral: ts.ObjectLiteralExpression; + decl: ts.VariableDeclaration; + source: ts.SourceFile; +}; + +/** anything that can be hoisted into the shared Configuration Options section */ +export type HoistEntry = InterfaceEntry | ValueShapeEntry; + +/** + * the hoist key for a parsed type: an interface uses its own name, while a + * `typeof x` value-shape uses the bare value name `x` (so the shared section is + * titled `x`, not `typeof x`). Kept in sync with the `typeof ` prefix that + * `parseTypeNode` writes for value-shape types. + */ +export function hoistKeyForType(type: string): string { + return type.startsWith("typeof ") ? type.slice("typeof ".length) : type; +} + +/** + * pairs together names of interface with their corresponding `ParameterInfo`s. + * makes it so we can hoist interfaces in one pass + */ +export type UsageMap = Map; + +function recordInterfaceUsage( + usageMap: UsageMap, + interfaceMap: Map, + info: ParameterInfo, +): void { + const key = hoistKeyForType(info.type); + if (!info.nested || !interfaceMap.has(key)) return; + const sites = usageMap.get(key); + if (sites) sites.push(info); + else usageMap.set(key, [info]); +} + +export function buildInterfaceMap( + source: ts.SourceFile, + program?: ts.Program, +): Map { + const map = new Map(); + const filesToSearch = program + ? [ + source, + ...program + .getSourceFiles() + .filter((f) => f !== source && !f.fileName.includes("/node_modules/")), + ] + : [source]; + for (const file of filesToSearch) { + for (const stmt of file.statements) { + if (ts.isInterfaceDeclaration(stmt) && !map.has(stmt.name.text)) { + map.set(stmt.name.text, { kind: "interface", decl: stmt, source: file }); + } + } + } + // second pass: object-valued consts, used to expand `typeof x` parameter types. + // interfaces win on name collisions (added first, and not overwritten here). + for (const file of filesToSearch) { + for (const stmt of file.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.initializer && + ts.isObjectLiteralExpression(decl.initializer) && + !map.has(decl.name.text) + ) { + map.set(decl.name.text, { + kind: "value", + objLiteral: decl.initializer, + decl, + source: file, + }); + } + } + } + } + return map; +} + +function resolveDefaultExpr( + initializer: ts.Expression, + source: ts.SourceFile, +): ts.Expression { + if (ts.isIdentifier(initializer)) { + for (const stmt of source.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === initializer.text && + decl.initializer + ) { + return decl.initializer; + } + } + } + } + return initializer; +} + +function getPropertyExpression( + objLiteral: ts.ObjectLiteralExpression, + propName: string, +): ts.Expression | undefined { + for (const prop of objLiteral.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + let name: string; + if (ts.isIdentifier(prop.name)) name = prop.name.text; + else if (ts.isStringLiteral(prop.name)) name = prop.name.text; + else continue; + if (name === propName) return prop.initializer; + } + return undefined; +} + +/** gathers jsdoc \@param tags to attach to regular params */ +function getJsDocParamDescriptions( + docNode: ts.Node, + source: ts.SourceFile, +): Record { + const result: Record = {}; + for (const tag of ts.getJSDocTags(docNode)) { + if (!ts.isJSDocParameterTag(tag) || !ts.isIdentifier(tag.name)) continue; + const raw = tag.comment; + const desc = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join("") + )?.trim(); + if (desc) result[tag.name.text] = desc; + } + return result; +} + +/** reads the description from a function's \@returns (or \@return) tag, if any */ +function getJsDocReturnDescription(docNode: ts.Node, source: ts.SourceFile): string | undefined { + for (const tag of ts.getJSDocTags(docNode)) { + if (tag.tagName.text !== "returns" && tag.tagName.text !== "return") continue; + const raw = tag.comment; + const desc = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join("") + ) + ?.replace(/\s*\n\s*/g, " ") + .trim(); + if (desc) return desc; + } + return undefined; +} + +/** collects the code blocks attached to each \@example tag on a function */ +function getJsDocExamples(docNode: ts.Node, source: ts.SourceFile): string[] { + const examples: string[] = []; + for (const tag of ts.getJSDocTags(docNode)) { + if (tag.tagName.text !== "example") continue; + const raw = tag.comment; + const text = typeof raw === "string" ? raw : raw?.map((n) => n.getText(source)).join(""); + const trimmed = text?.trim(); + if (trimmed) examples.push(trimmed); + } + return examples; +} + +function entityNameText(name: ts.EntityName): string { + if (ts.isIdentifier(name)) return name.text; + return entityNameText(name.left) + "." + name.right.text; +} + +// typeSource: the source file where typeNode is defined. +// defaultSource: the source file where defaultExpr is defined (always the main source file). +// These are tracked explicitly because ts.createProgram does not set parent nodes, +// so node.getSourceFile() and node.getText() without arguments are unavailable. +export function parseTypeNode( + typeNode: ts.TypeNode | undefined, + typeSource: ts.SourceFile, + defaultExpr: ts.Expression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): ParameterInfo { + const info: Partial = {}; + if (defaultExpr) info.default = defaultExpr.getText(defaultSource); + + if (!typeNode) { + info.type = "unknown"; + return info as ParameterInfo; + } + + if (ts.isArrayTypeNode(typeNode)) { + info.array = true; + const inner = parseTypeNode(typeNode.elementType, typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); + info.type = inner.type; + if (inner.nested) info.nested = inner.nested; + return info as ParameterInfo; + } + + if (ts.isTypeReferenceNode(typeNode)) { + const typeName = entityNameText(typeNode.typeName); + + if (typeName === "Array" && typeNode.typeArguments?.length === 1) { + info.array = true; + const inner = parseTypeNode(typeNode.typeArguments[0], typeSource, undefined, defaultSource, interfaceMap, visited, usageMap); + info.type = inner.type; + if (inner.nested) info.nested = inner.nested; + return info as ParameterInfo; + } + + // a generic instantiation (e.g. `Promise`, `Map`): keep the full + // type text instead of collapsing to the bare name, and don't attempt interface + // expansion/hoisting, a generic application isn't a plain interface to expand. + if (typeNode.typeArguments && typeNode.typeArguments.length > 0) { + info.type = printer + .printNode(ts.EmitHint.Unspecified, typeNode, typeSource) + .replace(/\s*\n\s*/g, " ") + .trim(); + return info as ParameterInfo; + } + + info.type = typeName; + + if (!visited.has(typeName)) { + const entry = interfaceMap.get(typeName); + if (entry?.kind === "interface") { + const defaultObjLiteral = + defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; + info.nested = parseInterfaceMembers( + entry, + defaultObjLiteral, + defaultSource, + interfaceMap, + new Set(visited).add(typeName), + usageMap, + ); + } + } + + return info as ParameterInfo; + } + + // `typeof x`: keep the readable `typeof x` label, but if `x` resolves to a known + // object-valued const, expand its shape so the type can be hoisted (and so a + // single use still renders its fields inline). + if (ts.isTypeQueryNode(typeNode)) { + const valueName = entityNameText(typeNode.exprName); + info.type = `typeof ${valueName}`; + if (!visited.has(valueName)) { + const entry = interfaceMap.get(valueName); + if (entry?.kind === "value") { + info.nested = parseValueShapeMembers(entry.objLiteral, entry.source); + } + } + return info as ParameterInfo; + } + + if (ts.isTypeLiteralNode(typeNode)) { + info.type = printer + .printNode(ts.EmitHint.Unspecified, typeNode, typeSource) + .replace(/\s*\n\s*/g, " ") + .replace(/; \}/g, " }") + .replace(/; /g, ", ") + .trim(); + const defaultObjLiteral = + defaultExpr && ts.isObjectLiteralExpression(defaultExpr) ? defaultExpr : undefined; + info.nested = parseTypeLiteralMembers(typeNode, typeSource, defaultObjLiteral, defaultSource, interfaceMap, visited, usageMap); + return info as ParameterInfo; + } + + info.type = printer.printNode(ts.EmitHint.Unspecified, typeNode, typeSource).trim(); + return info as ParameterInfo; +} + +export function parseInterfaceMembers( + entry: InterfaceEntry, + defaultObjLiteral: ts.ObjectLiteralExpression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): Record { + const result: Record = {}; + const { decl: interfaceDecl, source: memberSource } = entry; + for (const member of interfaceDecl.members) { + if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; + const name = member.name.text; + const memberDefault = defaultObjLiteral + ? getPropertyExpression(defaultObjLiteral, name) + : undefined; + const info = parseTypeNode(member.type, memberSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); + const desc = extractJsDocComment(member, memberSource); + if (desc) info.description = desc; + if (!info.default) { + const defaultTag = ts.getJSDocTags(member).find((t) => t.tagName.text === "default"); + if (defaultTag) { + const raw = defaultTag.comment; + const val = ( + typeof raw === "string" ? raw : raw?.map((n) => n.getText(memberSource)).join("") + )?.trim(); + if (val) info.default = val; + } + } + // intentionally not recorded in usageMap: hoisting only applies to types used directly + // as a function parameter (see parseFunctionParams), not to types nested inside another + // interface's members -- otherwise an interface nested inside an already-hoisted interface + // would appear to have 2+ usages (once per re-expansion of the outer interface) and get + // spuriously hoisted into its own orphaned, duplicated section. + result[name] = info; + } + return result; +} + +function parseTypeLiteralMembers( + typeNode: ts.TypeLiteralNode, + typeSource: ts.SourceFile, + defaultObjLiteral: ts.ObjectLiteralExpression | undefined, + defaultSource: ts.SourceFile, + interfaceMap: Map, + visited: Set, + usageMap: UsageMap, +): Record { + const result: Record = {}; + for (const member of typeNode.members) { + if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; + const name = member.name.text; + const memberDefault = defaultObjLiteral + ? getPropertyExpression(defaultObjLiteral, name) + : undefined; + const info = parseTypeNode(member.type, typeSource, memberDefault, defaultSource, interfaceMap, visited, usageMap); + const desc = extractJsDocComment(member, typeSource); + if (desc) info.description = desc; + // see comment in parseInterfaceMembers: nested member usage is intentionally not recorded + result[name] = info; + } + return result; +} + +/** + * Infers a parameter type string from a value expression (an object-const's + * property), since these are plain values with no type annotations. Resolves a + * single level of identifier reference (e.g. `instruction_pages` -> its array) + * and array element types one level deep. + */ +function inferValueType(expr: ts.Expression, source: ts.SourceFile, depth = 0): { type: string; array?: boolean } { + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr) || ts.isTemplateExpression(expr)) + return { type: "string" }; + if (ts.isNumericLiteral(expr)) return { type: "number" }; + if (expr.kind === ts.SyntaxKind.TrueKeyword || expr.kind === ts.SyntaxKind.FalseKeyword) + return { type: "boolean" }; + if (ts.isPrefixUnaryExpression(expr)) return inferValueType(expr.operand, source, depth); + if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) return { type: "function" }; + if (ts.isArrayLiteralExpression(expr)) { + const first = expr.elements[0]; + const inner = first && depth < 3 ? inferValueType(first, source, depth + 1) : { type: "unknown" }; + return { type: inner.type, array: true }; + } + if (ts.isObjectLiteralExpression(expr)) return { type: "object" }; + if (ts.isIdentifier(expr) && depth < 1) { + const resolved = resolveDefaultExpr(expr, source); + if (resolved !== expr) return inferValueType(resolved, source, depth + 1); + } + return { type: "unknown" }; +} + +/** + * parses an object-const's literal into `ParameterInfo`s for a `typeof x` type. + * field types are inferred from the values, and each value's source text is kept + * as the "default" (these values are the defaults a researcher would override). + */ +export function parseValueShapeMembers( + objLiteral: ts.ObjectLiteralExpression, + source: ts.SourceFile, +): Record { + const result: Record = {}; + for (const prop of objLiteral.properties) { + let name: string | undefined; + let valueExpr: ts.Expression | undefined; + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + name = prop.name.text; + valueExpr = prop.initializer; + } else if (ts.isShorthandPropertyAssignment(prop)) { + name = prop.name.text; + valueExpr = prop.name; + } else if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name)) { + name = prop.name.text; + } + if (!name) continue; + + const info = { type: "function" } as ParameterInfo; + if (valueExpr) { + const inferred = inferValueType(valueExpr, source); + info.type = inferred.type; + info.default = valueExpr.getText(source); + if (inferred.array) info.array = true; + } + const desc = extractJsDocComment(prop, source); + if (desc) info.description = desc; + result[name] = info; + } + return result; +} + +/** + * picks a display name (and JSDoc description) for a destructured parameter, + * which has no identifier of its own in source. timeline factories conventionally + * document the bag with a single `@param config ...` tag, so we adopt the first + * such "leftover" tag, one that doesn't name an identifier parameter, and fall + * back to `config` (suffixed on collision) when there is none. + */ +function resolveDestructuredName( + paramDescs: Record, + identifierParamNames: Set, + existing: Record, +): { name: string; description?: string } { + for (const [tagName, desc] of Object.entries(paramDescs)) { + if (!identifierParamNames.has(tagName) && !(tagName in existing)) { + return { name: tagName, description: desc }; + } + } + let name = "config"; + for (let i = 2; name in existing; i++) name = `config${i}`; + return { name }; +} + +/** + * handles destructured object parameters (like `{ ... }: Config = {}`) as a + * single parameter typed by annotations. per-field defaults are on binding elements, + * not in the object literal. this also allows for shared config interfaces to be hoisted + */ +function parseDestructuredParam( + param: ts.ParameterDeclaration, + pattern: ts.ObjectBindingPattern, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): ParameterInfo { + const defaultExpr = + param.initializer && ts.isObjectLiteralExpression(param.initializer) + ? param.initializer + : undefined; + const info = parseTypeNode(param.type, source, defaultExpr, source, interfaceMap, new Set(), usageMap); + + if (info.nested) { + for (const element of pattern.elements) { + if (!ts.isIdentifier(element.name) || !element.initializer) continue; + const target = info.nested[element.name.text]; + if (target && !target.default) target.default = element.initializer.getText(source); + } + } + return info; +} + +/** + * parses the parameter list of a function-like node into `ParameterInfo` records. + * + * @param signatureNode the node whose `.parameters` are read (a function/method + * declaration, or an arrow/function expression assigned to a class property) + * @param docNode the node the TSDoc `@param` tags are attached to -- usually the + * same node, but for an arrow-valued property the doc lives on the property + * declaration while the parameters live on the initializer + */ +export function parseFunctionParams( + signatureNode: ts.SignatureDeclarationBase, + docNode: ts.Node, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): Record { + const result: Record = {}; + const params = [...signatureNode.parameters]; + const paramDescs = getJsDocParamDescriptions(docNode, source); + const identifierParamNames = new Set( + params.filter((p) => ts.isIdentifier(p.name)).map((p) => (p.name as ts.Identifier).text), + ); + for (const param of params) { + if (ts.isIdentifier(param.name)) { + const name = param.name.text; + const defaultExpr = param.initializer + ? resolveDefaultExpr(param.initializer, source) + : undefined; + const info = parseTypeNode(param.type, source, defaultExpr, source, interfaceMap, new Set(), usageMap); + const desc = paramDescs[name]; + if (desc) info.description = desc; + recordInterfaceUsage(usageMap, interfaceMap, info); + result[name] = info; + } else if (ts.isObjectBindingPattern(param.name)) { + const info = parseDestructuredParam(param, param.name, source, interfaceMap, usageMap); + const { name, description } = resolveDestructuredName(paramDescs, identifierParamNames, result); + if (description && !info.description) info.description = description; + recordInterfaceUsage(usageMap, interfaceMap, info); + result[name] = info; + } + // array binding patterns and other parameter shapes are intentionally skipped + } + return result; +} + +// --- CLASS MEMBER PARSING (plugins/extensions) --- + +/** the plugin lifecycle methods that every plugin defines; excluded from helper docs */ +export const PLUGIN_LIFECYCLE_METHODS = new Set(["trial", "simulate"]); + +/** the extension lifecycle methods that every extension defines; excluded from helper docs */ +export const EXTENSION_LIFECYCLE_METHODS = new Set([ + "initialize", + "on_start", + "on_load", + "on_finish", +]); + +const VOID_RETURN_TYPES = new Set(["void", "undefined", "never"]); + +/** builds the return info for a function, or `undefined` for an unannotated/`void` return */ +function getReturnInfo( + signatureNode: ts.SignatureDeclarationBase, + docNode: ts.Node, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): ReturnInfo | undefined { + if (!signatureNode.type) return undefined; + const parsed = parseTypeNode(signatureNode.type, source, undefined, source, interfaceMap, new Set(), usageMap); + if (VOID_RETURN_TYPES.has(parsed.type)) return undefined; + const info: ReturnInfo = { type: parsed.type }; + if (parsed.array) info.array = true; + const description = getJsDocReturnDescription(docNode, source); + if (description) info.description = description; + return info; +} + +/** + * parses a single class member into a `FunctionInfo`, or returns `undefined` when + * the member is not a documentable helper function. a member is documented when it + * is a public (not `private`/`protected`) method, or a public property whose + * initializer is an arrow/function expression. the parameters/return come from the + * signature; the description/`@param`/`@returns`/`@example` come from the member's TSDoc. + */ +export function parseFunction( + member: ts.MethodDeclaration | ts.PropertyDeclaration, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): { name: string; info: FunctionInfo } | undefined { + // skip computed names and ecmascript-private (`#name`) members + if (!member.name || !ts.isIdentifier(member.name)) return undefined; + + const modifiers = member.modifiers ?? []; + const isPrivate = modifiers.some( + (m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword, + ); + if (isPrivate) return undefined; + + let signatureNode: ts.SignatureDeclarationBase | undefined; + if (ts.isMethodDeclaration(member)) { + signatureNode = member; + } else if ( + ts.isPropertyDeclaration(member) && + member.initializer && + (ts.isArrowFunction(member.initializer) || ts.isFunctionExpression(member.initializer)) + ) { + signatureNode = member.initializer; + } + if (!signatureNode) return undefined; + + const isStatic = modifiers.some((m) => m.kind === ts.SyntaxKind.StaticKeyword); + const info: FunctionInfo = { + description: extractJsDocComment(member, source) ?? "", + isStatic, + parameters: parseFunctionParams(signatureNode, member, source, interfaceMap, usageMap), + returns: getReturnInfo(signatureNode, member, source, interfaceMap, usageMap), + examples: getJsDocExamples(member, source), + }; + return { name: member.name.text, info }; +} + +/** + * Collects all documentable helper functions from a plugin/extension class, + * excluding the required lifecycle methods named in `denyList` (and the + * constructor, which is never a `MethodDeclaration`/`PropertyDeclaration`). + * + * Interface expansion is single-file: types referenced by a helper's parameters + * are only resolved if declared in the same source file. Hoisting of shared + * interfaces is intentionally not applied here (the usage map is discarded). + */ +export function collectClassFunctions( + classNode: ts.ClassDeclaration, + source: ts.SourceFile, + denyList: Set, +): Record { + const interfaceMap = buildInterfaceMap(source); + const usageMap: UsageMap = new Map(); + const result: Record = {}; + for (const member of classNode.members) { + if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member)) continue; + if (member.name && ts.isIdentifier(member.name) && denyList.has(member.name.text)) continue; + const parsed = parseFunction(member, source, interfaceMap, usageMap); + if (parsed) result[parsed.name] = parsed.info; + } + return result; } \ No newline at end of file diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index 1c9630f..7346ba7 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -1,5 +1,5 @@ import { ExtensionInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, renderSections, topParameterChart, topDataChart } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, renderSections, topParameterChart, topDataChart } from "./utils.js"; const getTypeName = (type: string, array?: boolean): string => array ? `array of ${type}` : type; @@ -81,6 +81,17 @@ ${rows ?? "*None*"} `.trim(); }, }, + { + heading: "functions", + render: (info) => { + return `## Functions + +In addition to the standard extension lifecycle methods, this extension exposes the following helper functions. + +${renderFunctionGroup(info.functions)} +`.trim(); + }, + }, { heading: "examples", render: (info) => { diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index f5c2eb9..db9c2e3 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,5 +1,5 @@ import { PluginInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; const stringifyTypeMap = PARAMETER_TYPE_MAP; @@ -46,6 +46,17 @@ In addition to the [default data collected by all plugins](https://www.jspsych.o ${topDataChart} ${rows ?? "*None*"} +`.trim(); + }, + }, + { + heading: "functions", + render: (info) => { + return `## Functions + +In addition to the standard plugin lifecycle methods, this plugin exposes the following helper functions. + +${renderFunctionGroup(info.functions)} `.trim(); }, }, diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts index 0ebb7c3..d4b31a7 100644 --- a/packages/autodoc/src/renderers/utils.ts +++ b/packages/autodoc/src/renderers/utils.ts @@ -1,4 +1,4 @@ -import { ParameterInfo, SectionTemplate } from "../types/info.js"; +import { FunctionInfo, ParameterInfo, SectionTemplate } from "../types/info.js"; /** * renders a given template with `info`, returning a record keyed with the @@ -92,4 +92,45 @@ export const renderDataRow = (name: string, parameter: ParameterInfo, typeToStri ? `${parameter.description} ${renderNestedDataDescription(parameter.nested)}` : parameter.description; return `| ${name} | ${typeToString(parameter.type, parameter.array)} | ${value} |`; +}; + +/** function parameter/return types come from TS annotations already, so they only need array-wrapping */ +const renderFunctionTypeName = (type: string, array?: boolean): string => + array ? `array of ${type}` : type; + +const renderFunction = (name: string, fn: FunctionInfo): string => { + const signature = `${fn.isStatic ? "static " : ""}${name}(${Object.keys(fn.parameters).join(", ")})`; + const parts: string[] = [`### \`${signature}\``]; + + parts.push(fn.description?.trim() || "*No description provided.*"); + + if (Object.keys(fn.parameters).length > 0) { + const rows = Object.entries(fn.parameters) + .map(([paramName, param]) => renderParameterRow(paramName, param, renderFunctionTypeName)) + .join("\n"); + parts.push(`${topParameterChart}\n${rows}`); + } + + if (fn.returns) { + const type = renderFunctionTypeName(fn.returns.type, fn.returns.array); + const description = fn.returns.description ? ` — ${fn.returns.description}` : ""; + parts.push(`**Returns:** \`${type}\`${description}`); + } + + for (const example of fn.examples) { + parts.push(`\`\`\`js\n${example}\n\`\`\``); + } + + return parts.join("\n\n"); +}; + +/** + * Renders a group of helper functions as classic function documentation: a + * signature heading, description, parameter table, return value, and any + * `@example` code blocks. Shared by the plugin and extension renderers. + */ +export const renderFunctionGroup = (functions: Record): string => { + const entries = Object.entries(functions); + if (entries.length === 0) return "*None*"; + return entries.map(([name, fn]) => renderFunction(name, fn)).join("\n\n"); }; \ No newline at end of file diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts index ad21da6..3f8526d 100644 --- a/packages/autodoc/src/types/info.ts +++ b/packages/autodoc/src/types/info.ts @@ -1,9 +1,11 @@ export interface PluginInfo { - name: string; + name: string; description: string; version: string; // gathered from package.json parameters: Record; data: Record; + /** public (static or instance) helper methods, excluding the required `trial`/`simulate` lifecycle methods */ + functions: Record; examples: Record; } @@ -16,6 +18,8 @@ export interface ExtensionInfo { onLoadParameters: Record; onFinishParameters: Record; data: Record; + /** public (static or instance) helper methods, excluding the required `initialize`/`on_start`/`on_load`/`on_finish` lifecycle methods */ + functions: Record; examples: Record; } @@ -43,6 +47,22 @@ export interface TimelineHelperInfo { helperParameters: Record; } +/** name is attached via record */ +export interface FunctionInfo { + description: string; + isStatic: boolean; + parameters: Record; + /** not present = returns nothing/void */ + returns?: ReturnInfo; + examples: string[]; +} + +export interface ReturnInfo { + type: string; + array?: boolean; + description?: string; +} + /** name is attached via record */ export interface ParameterInfo { type: string; diff --git a/packages/autodoc/tests/fixtures/extension/basic.ts b/packages/autodoc/tests/fixtures/extension/basic.ts index a2d59ee..8b0561f 100644 --- a/packages/autodoc/tests/fixtures/extension/basic.ts +++ b/packages/autodoc/tests/fixtures/extension/basic.ts @@ -90,4 +90,27 @@ class TestExtension implements JsPsychExtension { }, }, }; + + // initialize is a lifecycle method and must be excluded from the docs + initialize(_params: InitializeParameters): Promise { + return Promise.resolve(); + } + + // on_start is a lifecycle method and must be excluded from the docs + on_start(_params: OnStartParameters): void {} + + /** + * Averages a list of samples. + * @param samples the raw samples + * @returns the mean value + */ + static average(samples: number[]): number { + return 0; + } + + /** Clears the collected samples. */ + clear(): void {} + + /** internal helper, should not be documented */ + private tick(): void {} } diff --git a/packages/autodoc/tests/fixtures/plugin/basic.ts b/packages/autodoc/tests/fixtures/plugin/basic.ts index 8fffa0c..7614e54 100644 --- a/packages/autodoc/tests/fixtures/plugin/basic.ts +++ b/packages/autodoc/tests/fixtures/plugin/basic.ts @@ -7,6 +7,46 @@ import { ParameterType, JsPsychPlugin } from "../../utils.js" /** A test jsPsych plugin. */ class TestPlugin implements JsPsychPlugin { trial(_display_element: HTMLElement, _trial: typeof info.parameters): void {} + + // simulate is a lifecycle method and must be excluded from the docs + simulate(_trial: typeof info.parameters): void {} + + /** + * Computes a score from a response. + * @param response the participant's response + * @param partialCredit whether partial credit is allowed + * @returns the numeric score + * @example + * const s = TestPlugin.computeScore("a", true); + */ + static computeScore(response: string, partialCredit: boolean = false): number { + return 0; + } + + /** + * Configures the plugin. + * @param options the configuration bag + */ + configure(options: { verbose: boolean; retries: number }): void {} + + /** helper that should never be documented */ + private cleanup(): void {} + + /** + * Formats a label. + * @param label the raw label + * @returns the upper-cased label + */ + formatLabel = (label: string): string => label.toUpperCase(); + + /** + * Loads a remote resource. + * @param url the resource url + * @returns the parsed payload + */ + static async load(url: string): Promise> { + return {}; + } } const info = { diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 6916dd4..517ac33 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -93,6 +93,36 @@ describe('getExtensionInfo', () => { }); }); +describe('getExtensionInfo helper functions', () => { + const parse = () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + return getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); + }; + + it('collects public helpers, excluding lifecycle and private members', () => { + const info = parse(); + expect(Object.keys(info.functions).sort()).toEqual(['average', 'clear']); + expect(info.functions.initialize).toBeUndefined(); + expect(info.functions.on_start).toBeUndefined(); + expect(info.functions.tick).toBeUndefined(); + }); + + it('parses a static helper with an array param and a return', () => { + const { average } = parse().functions; + expect(average.isStatic).toBe(true); + expect(average.parameters.samples.type).toBe('number'); + expect(average.parameters.samples.array).toBe(true); + expect(average.returns?.type).toBe('number'); + expect(average.returns?.description).toBe('the mean value'); + }); + + it('omits a void return on an instance helper', () => { + const { clear } = parse().functions; + expect(clear.isStatic).toBe(false); + expect(clear.returns).toBeUndefined(); + }); +}); + describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => { const inferTestsDir = path.resolve(__dirname, '../fixtures/extension/infer-tests'); let classNode: ts.ClassDeclaration; diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index 5cc95e0..d5de59d 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -52,6 +52,55 @@ describe('getPluginInfo', () => { }); }); +describe('getPluginInfo helper functions', () => { + const parse = () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + return getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); + }; + + it('collects public static and instance helpers, excluding lifecycle and private members', () => { + const info = parse(); + expect(Object.keys(info.functions).sort()).toEqual(['computeScore', 'configure', 'formatLabel', 'load']); + expect(info.functions.trial).toBeUndefined(); + expect(info.functions.simulate).toBeUndefined(); + expect(info.functions.cleanup).toBeUndefined(); + }); + + it('preserves generic type arguments in a return type', () => { + const { load } = parse().functions; + expect(load.returns?.type).toBe('Promise>'); + expect(load.returns?.description).toBe('the parsed payload'); + }); + + it('marks static members and records params, return, and example', () => { + const { computeScore } = parse().functions; + expect(computeScore.isStatic).toBe(true); + expect(computeScore.parameters.response.type).toBe('string'); + expect(computeScore.parameters.response.description).toBe("the participant's response"); + expect(computeScore.parameters.partialCredit.type).toBe('boolean'); + expect(computeScore.parameters.partialCredit.default).toBe('false'); + expect(computeScore.returns?.type).toBe('number'); + expect(computeScore.returns?.description).toBe('the numeric score'); + expect(computeScore.examples).toEqual(['const s = TestPlugin.computeScore("a", true);']); + }); + + it('expands inline object-literal parameter types and omits a void return', () => { + const { configure } = parse().functions; + expect(configure.isStatic).toBe(false); + expect(configure.parameters.options.nested?.verbose.type).toBe('boolean'); + expect(configure.parameters.options.nested?.retries.type).toBe('number'); + expect(configure.returns).toBeUndefined(); + }); + + it('parses arrow-function properties, reading TSDoc from the property', () => { + const { formatLabel } = parse().functions; + expect(formatLabel.isStatic).toBe(false); + expect(formatLabel.parameters.label.description).toBe('the raw label'); + expect(formatLabel.returns?.type).toBe('string'); + expect(formatLabel.returns?.description).toBe('the upper-cased label'); + }); +}); + describe('getPluginInfoAndExamples', () => { const examplesDir = path.resolve(__dirname, '../fixtures/plugin/examples'); diff --git a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap index ebef847..220e30e 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap @@ -18,6 +18,15 @@ exports[`extension renderer (default template) matches the rendered snapshot 1`] initJsPsych({ extensions: [...] }); \`\`\` ", + "functions": " +## Functions + +In addition to the standard extension lifecycle methods, this extension exposes the following helper functions. + +### \`reset()\` + +Clears the collected samples. +", "init-parameters": " ### Initialization Parameters Initialization parameters are set when calling \`initJsPsych()\`. diff --git a/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap index af0338a..6f1e937 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap @@ -21,6 +21,26 @@ In addition to the [default data collected by all plugins](https://www.jspsych.o const trial = { type: jsPsychTestPlugin }; \`\`\` ", + "functions": " +## Functions + +In addition to the standard plugin lifecycle methods, this plugin exposes the following helper functions. + +### \`static computeScore(response, partialCredit)\` + +Computes the score for a response. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| response | string | \`undefined\` | The participant response. | +| partialCredit | boolean | \`false\` | Whether to allow partial credit. | + +**Returns:** \`number\` — The computed score. + +\`\`\`js +jsPsychTestPlugin.computeScore("a", true); +\`\`\` +", "introduction": " # test-plugin diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index cebf506..0899d6b 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -16,6 +16,14 @@ const info: ExtensionInfo = { data: { samples: { type: "object", default: "", description: "Collected samples." }, }, + functions: { + reset: { + description: "Clears the collected samples.", + isStatic: false, + parameters: {}, + examples: [], + }, + }, examples: { "Basic example": { path: "examples/basic.html", code: "initJsPsych({ extensions: [...] });" }, }, @@ -31,10 +39,17 @@ describe("extension renderer (default template)", () => { "init-parameters", "trial-parameters", "data", + "functions", "examples", ]); }); + it("renders instance helper functions without a static marker", () => { + expect(docs.functions).toContain("### `reset()`"); + expect(docs.functions).not.toContain("static reset"); + expect(docs.functions).toContain("Clears the collected samples."); + }); + it("derives the jsPsychExtension name in the usage snippets", () => { expect(docs["init-parameters"]).toContain("jsPsychExtensionTestExtension"); }); diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index f4dc526..a479bc6 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -16,6 +16,18 @@ const info: PluginInfo = { rt: { type: "ParameterType.INT", default: "", description: "Response time in ms." }, response: { type: "ParameterType.STRING", default: "", description: "The key pressed." }, }, + functions: { + computeScore: { + description: "Computes the score for a response.", + isStatic: true, + parameters: { + response: { type: "string", default: "undefined", description: "The participant response." }, + partialCredit: { type: "boolean", default: "false", description: "Whether to allow partial credit." }, + }, + returns: { type: "number", description: "The computed score." }, + examples: ['jsPsychTestPlugin.computeScore("a", true);'], + }, + }, examples: { "Basic example": { path: "examples/basic.html", code: "const trial = { type: jsPsychTestPlugin };" }, }, @@ -25,7 +37,14 @@ describe("plugin renderer (default template)", () => { const docs = getPluginDocs(info); it("produces the default sections", () => { - expect(Object.keys(docs)).toEqual(["introduction", "parameters", "data", "examples"]); + expect(Object.keys(docs)).toEqual(["introduction", "parameters", "data", "functions", "examples"]); + }); + + it("renders helper functions with signature, return, and example", () => { + expect(docs.functions).toContain("### `static computeScore(response, partialCredit)`"); + expect(docs.functions).toContain("Computes the score for a response."); + expect(docs.functions).toContain("**Returns:** `number` — The computed score."); + expect(docs.functions).toContain('jsPsychTestPlugin.computeScore("a", true);'); }); it("maps ParameterType values to human-readable names", () => { From 4ed1f475eea21141cdbd50a260fdfc8e2483dc5a Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:11:25 -0400 Subject: [PATCH 23/26] reduce functions/examples to empty strings if none are found --- packages/autodoc/src/renderers/extension.ts | 2 ++ packages/autodoc/src/renderers/plugin.ts | 2 ++ packages/autodoc/src/renderers/timeline.ts | 4 +++- .../__snapshots__/timeline.test.ts.snap | 4 ---- .../autodoc/tests/renderers/extension.test.ts | 12 ++++++++++++ packages/autodoc/tests/renderers/plugin.test.ts | 17 +++++++++++++++++ .../autodoc/tests/renderers/timeline.test.ts | 12 ++++++++++++ 7 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index 7346ba7..e7448c4 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -84,6 +84,7 @@ ${rows ?? "*None*"} { heading: "functions", render: (info) => { + if (Object.keys(info.functions).length === 0) return ""; return `## Functions In addition to the standard extension lifecycle methods, this extension exposes the following helper functions. @@ -95,6 +96,7 @@ ${renderFunctionGroup(info.functions)} { heading: "examples", render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; const sections = Object.entries(info.examples) .map( ([title, example]) => diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index db9c2e3..edaca34 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -52,6 +52,7 @@ ${rows ?? "*None*"} { heading: "functions", render: (info) => { + if (Object.keys(info.functions).length === 0) return ""; return `## Functions In addition to the standard plugin lifecycle methods, this plugin exposes the following helper functions. @@ -63,6 +64,7 @@ ${renderFunctionGroup(info.functions)} { heading: "examples", render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; const sections = Object.entries(info.examples) .map( ([title, example]) => diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts index 8e149a2..8af305d 100644 --- a/packages/autodoc/src/renderers/timeline.ts +++ b/packages/autodoc/src/renderers/timeline.ts @@ -103,6 +103,7 @@ ${renderHelperGroup(info.utils)}`, { heading: "configuration-options", render: (info) => { + if (Object.keys(info.interfaces).length === 0) return ""; const sections = Object.entries(info.interfaces) .map(([name, interfaceInfo]: [string, TimelineInterfaceInfo]) => { const description = interfaceInfo.description || "*No description provided.*"; @@ -114,12 +115,13 @@ ${renderHelperGroup(info.utils)}`, These types are shared by multiple parameters above. -${sections || "*None*"}`; +${sections}`; }, }, { heading: "examples", render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; const sections = Object.entries(info.examples) .map( ([title, example]) => diff --git a/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap index 8a1536b..751f2e7 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap @@ -6,11 +6,7 @@ exports[`timeline renderer (default template) matches the rendered snapshot 1`] ## API Reference ", "configuration-options": " -## Configuration Options -These types are shared by multiple parameters above. - -*None* ", "create-timeline": " ### \`createTimeline()\` diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index 0899d6b..4e27975 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -66,3 +66,15 @@ describe("extension renderer (default template)", () => { expect(getExtensionDocs(info)).toMatchSnapshot(); }); }); + +describe("extension renderer (empty optional sections)", () => { + const emptyInfo: ExtensionInfo = { ...info, functions: {}, examples: {} }; + const docs = getExtensionDocs(emptyInfo); + + it("keeps the section anchors while omitting the visible headings", () => { + expect(docs.functions).toContain("jspsych-autodocs:functions:start"); + expect(docs.functions).not.toContain("## Functions"); + expect(docs.examples).toContain("jspsych-autodocs:examples:start"); + expect(docs.examples).not.toContain("## Examples"); + }); +}); diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index a479bc6..685ede2 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -69,6 +69,23 @@ describe("plugin renderer (default template)", () => { }); }); +describe("plugin renderer (empty optional sections)", () => { + const emptyInfo: PluginInfo = { ...info, functions: {}, examples: {} }; + const docs = getPluginDocs(emptyInfo); + + it("keeps every section anchor so re-runs stay idempotent", () => { + expect(Object.keys(docs)).toEqual(["introduction", "parameters", "data", "functions", "examples"]); + expect(docs.functions).toContain("jspsych-autodocs:functions:start"); + expect(docs.examples).toContain("jspsych-autodocs:examples:start"); + }); + + it("omits the visible heading when a section is empty", () => { + expect(docs.functions).not.toContain("## Functions"); + expect(docs.functions).not.toContain("*None*"); + expect(docs.examples).not.toContain("## Examples"); + }); +}); + describe("plugin renderer (custom template)", () => { it("fully replaces the default sections", () => { const custom: SectionTemplate[] = [ diff --git a/packages/autodoc/tests/renderers/timeline.test.ts b/packages/autodoc/tests/renderers/timeline.test.ts index 16711b6..54ca60d 100644 --- a/packages/autodoc/tests/renderers/timeline.test.ts +++ b/packages/autodoc/tests/renderers/timeline.test.ts @@ -41,6 +41,18 @@ describe("timeline renderer (default template)", () => { expect(docs["create-timeline"]).toContain("Builds the timeline."); }); + it("collapses configuration-options when there are no shared interfaces", () => { + expect(docs["configuration-options"]).toContain("jspsych-autodocs:configuration-options:start"); + expect(docs["configuration-options"]).not.toContain("## Configuration Options"); + expect(docs["configuration-options"]).not.toContain("*None*"); + }); + + it("collapses examples when there are none", () => { + const emptyExamples = getTimelineDocs({ ...info, examples: {} }); + expect(emptyExamples.examples).toContain("jspsych-autodocs:examples:start"); + expect(emptyExamples.examples).not.toContain("## Examples"); + }); + it("matches the rendered snapshot", () => { expect(getTimelineDocs(info)).toMatchSnapshot(); }); From ae138e4fa1cb269a16dc2c3952ff26a0b8d6c22a Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:56:16 -0400 Subject: [PATCH 24/26] remove package name (first bolded word) in description for renderers --- packages/autodoc/src/cli.ts | 1 + packages/autodoc/src/renderers/extension.ts | 4 +-- packages/autodoc/src/renderers/plugin.ts | 4 +-- packages/autodoc/src/renderers/timeline.ts | 4 +-- packages/autodoc/src/renderers/utils.ts | 12 ++++++++ .../autodoc/tests/renderers/plugin.test.ts | 6 ++++ .../autodoc/tests/renderers/utils.test.ts | 30 ++++++++++++++++++- 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 7c0a724..5ff7c5f 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -80,6 +80,7 @@ async function main(options: CliOptions): Promise { const sourcePath = options.source ?? discoverSource(anchor()); const packageJsonPath = options.packageJson ?? path.join(anchor(), "package.json"); + console.log(sourcePath); // example discovery is optional, so shouldn't really have to deal w/ fail-fast // behavior if not necessary diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index e7448c4..1b2917f 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -1,5 +1,5 @@ import { ExtensionInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, renderFunctionGroup, renderSections, topParameterChart, topDataChart } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, removePackageName, renderSections, topParameterChart, topDataChart } from "./utils.js"; const getTypeName = (type: string, array?: boolean): string => array ? `array of ${type}` : type; @@ -13,7 +13,7 @@ export const defaultExtensionTemplate: SectionTemplate[] = [ render: (info) => { return `# ${info.name} -${info.description} +${removePackageName(info.description)} Current version: ${info.version}`.trim(); }, diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index edaca34..9ac4a4d 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -1,5 +1,5 @@ import { PluginInfo, SectionTemplate } from "../types/info.js"; -import { renderParameterRow, renderDataRow, renderFunctionGroup, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, removePackageName, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; const stringifyTypeMap = PARAMETER_TYPE_MAP; @@ -14,7 +14,7 @@ export const defaultPluginTemplate: SectionTemplate[] = [ render: (info) => { return `# ${info.name} -${info.description} +${removePackageName(info.description)} Current version: ${info.version}`.trim(); }, diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts index 8af305d..e47000a 100644 --- a/packages/autodoc/src/renderers/timeline.ts +++ b/packages/autodoc/src/renderers/timeline.ts @@ -1,5 +1,5 @@ import { SectionTemplate, TimelineInfo, TimelineHelperInfo, TimelineInterfaceInfo, ParameterInfo } from "../types/info.js"; -import { renderSections, topParameterChart } from "./utils.js"; +import { removePackageName, renderSections, topParameterChart } from "./utils.js"; const getTypeName = (type: string, array?: boolean): string => (array ? `array of ${type}` : type); @@ -61,7 +61,7 @@ export const defaultTimelineTemplate: SectionTemplate[] = [ render: (info) => { return `# ${info.name} -${info.description} +${removePackageName(info.description)} Current version: ${info.version}`.trim(); }, diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts index d4b31a7..1cc2c5c 100644 --- a/packages/autodoc/src/renderers/utils.ts +++ b/packages/autodoc/src/renderers/utils.ts @@ -18,6 +18,18 @@ export function renderSections( ); } +/** + * Strips a leading bold "title" from a parsed class description. jsPsych plugin, + * extension, and timeline docblocks conventionally open with the package name wrapped + * in bold (e.g. `**plugin-redirect-to-url**` or `**jsPsychPipe**`) before the real + * description. The exact form of that name isn't predictable, so we remove whatever + * leading `**...**` bold span is present. Descriptions with no leading bold span are + * returned unchanged. + */ +export function removePackageName(description: string): string { + return description.replace(/^\s*\*\*.+?\*\*\s*/, "").trim(); +} + export const PARAMETER_TYPE_MAP: Record = { "ParameterType.STRING": "string", "ParameterType.INT": "integer", diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index 685ede2..4acd29d 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -53,6 +53,12 @@ describe("plugin renderer (default template)", () => { expect(docs.parameters).toContain("array of keys"); }); + it("strips the bold package-name title from the introduction", () => { + const withTitle = getPluginDocs({ ...info, description: "**jsPsychTest** A test plugin." }); + expect(withTitle.introduction).toContain("A test plugin."); + expect(withTitle.introduction).not.toContain("**jsPsychTest**"); + }); + it("renders data rows and examples", () => { expect(docs.data).toContain("Response time in ms."); expect(docs.examples).toContain("examples/basic.html"); diff --git a/packages/autodoc/tests/renderers/utils.test.ts b/packages/autodoc/tests/renderers/utils.test.ts index f60f776..c304cba 100644 --- a/packages/autodoc/tests/renderers/utils.test.ts +++ b/packages/autodoc/tests/renderers/utils.test.ts @@ -1,6 +1,34 @@ -import { renderSections, PARAMETER_TYPE_MAP } from "../../src/renderers/utils.js"; +import { removePackageName, renderSections, PARAMETER_TYPE_MAP } from "../../src/renderers/utils.js"; import { SectionTemplate } from "../../src/types/info.js"; +describe("removePackageName", () => { + it("strips a leading bold title and the whitespace after it", () => { + expect( + removePackageName("**plugin-redirect-to-url** The redirect-to-url plugin does things."), + ).toBe("The redirect-to-url plugin does things."); + }); + + it("works regardless of the form the bolded name takes", () => { + expect(removePackageName("**jsPsychPipe** This plugin facilitates communication.")).toBe( + "This plugin facilitates communication.", + ); + }); + + it("only removes the first (leading) bold span", () => { + expect(removePackageName("**title** keep **this** bold")).toBe("keep **this** bold"); + }); + + it("returns a description with no leading bold span unchanged", () => { + expect(removePackageName("A test plugin.")).toBe("A test plugin."); + }); + + it("does not strip a bold span that is not at the very start", () => { + expect(removePackageName("See **the docs** for details.")).toBe( + "See **the docs** for details.", + ); + }); +}); + describe("PARAMETER_TYPE_MAP", () => { it("maps common ParameterType values to human-readable strings", () => { expect(PARAMETER_TYPE_MAP["ParameterType.BOOL"]).toBe("boolean"); From a3b052370ae68c148e62241ce44234afca18f87b Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:02:06 -0400 Subject: [PATCH 25/26] parser wraps default values with backticks in double backticks to render properly --- packages/autodoc/src/parsers/utils.ts | 11 ++++++- .../autodoc/tests/fixtures/plugin/basic.ts | 5 ++++ packages/autodoc/tests/parsers/plugin.test.ts | 15 ++++++++++ .../autodoc/tests/renderers/utils.test.ts | 29 +++++++++++++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 180ddc5..0320844 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -20,6 +20,15 @@ export function extractJsDocComment(node: ts.Node, source: ts.SourceFile): strin .trim(); } +/** + * pads a backtick-surrounded string with two backticks and a space, + * in order to ensure that the original backticks are not interpreted + * as markdown code delimiters. + */ +function wrapBackticks(text: string): string { + return text.includes("`") ? `\` ${text} \`` : text; +} + /** Parses one parameter from an parameter node. */ export function parseParamGroup( node: ts.ObjectLiteralExpression, @@ -55,7 +64,7 @@ function extractParameter(node: ts.ObjectLiteralExpression, source: ts.SourceFil break; } case "default": { - result.default = prop.initializer.getText(source); + result.default = wrapBackticks(prop.initializer.getText(source)); break; } case "array": { diff --git a/packages/autodoc/tests/fixtures/plugin/basic.ts b/packages/autodoc/tests/fixtures/plugin/basic.ts index 7614e54..220d796 100644 --- a/packages/autodoc/tests/fixtures/plugin/basic.ts +++ b/packages/autodoc/tests/fixtures/plugin/basic.ts @@ -72,6 +72,11 @@ const info = { default: [], array: true, }, + /** A prompt rendered as HTML. */ + prompt: { + type: ParameterType.HTML_STRING, + default: `

string

`, + }, /** Now let's have a grid. */ grid: { type: ParameterType.COMPLEX, diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index d5de59d..37f3658 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -36,6 +36,21 @@ describe('getPluginInfo', () => { expect(info.parameters.double_double.description).toBe('Multi-line description. It has two lines for a parameter.'); }); + it('wraps a template-literal default so its backticks survive the renderer', () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); + // padded with a space + extra backtick per side so the renderer's single-backtick + // wrap becomes a double-backtick span, keeping the inner backticks visible. + expect(info.parameters.prompt.default).toBe('` `

string

` `'); + }); + + it('leaves non-template-literal defaults unescaped', () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); + expect(info.parameters.double_double.default).toBe('42'); + expect(info.parameters.list_of_stimuli.default).toBe('[]'); + }); + it('extracts array flag on parameters', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); diff --git a/packages/autodoc/tests/renderers/utils.test.ts b/packages/autodoc/tests/renderers/utils.test.ts index c304cba..a7ee057 100644 --- a/packages/autodoc/tests/renderers/utils.test.ts +++ b/packages/autodoc/tests/renderers/utils.test.ts @@ -1,5 +1,5 @@ -import { removePackageName, renderSections, PARAMETER_TYPE_MAP } from "../../src/renderers/utils.js"; -import { SectionTemplate } from "../../src/types/info.js"; +import { removePackageName, renderSections, PARAMETER_TYPE_MAP, renderParameterRow } from "../../src/renderers/utils.js"; +import { ParameterInfo, SectionTemplate } from "../../src/types/info.js"; describe("removePackageName", () => { it("strips a leading bold title and the whitespace after it", () => { @@ -44,6 +44,31 @@ describe("PARAMETER_TYPE_MAP", () => { }); }); +describe("renderParameterRow", () => { + const identity = (type: string, array?: boolean) => (array ? `array of ${type}` : type); + + it("keeps a padded backtick default visible as a double-backtick code span", () => { + const param: ParameterInfo = { + type: "HTML string", + // as produced by the parser's wrapBackticks for a `

string

` template default + default: "` `

string

` `", + description: "A prompt.", + }; + // the renderer's single-backtick wrap combines with the padding to form a + // double-backtick span, so the inner backticks are not swallowed. + expect(renderParameterRow("prompt", param, identity)).toBe( + "| prompt | HTML string | `` `

string

` `` | A prompt. |", + ); + }); + + it("renders numeric defaults without an inline-code span", () => { + const param: ParameterInfo = { type: "integer", default: "42", description: "A count." }; + expect(renderParameterRow("count", param, identity)).toBe( + "| count | integer | 42 | A count. |", + ); + }); +}); + // `renderSections` is the shared, type-agnostic wrapper behind every `get*Docs` // function. It does not know about any particular `*Info` shape or default // template, so it is tested generically with a throwaway info object and a From 3559a023714090389caf05ee0669e1b9b704aa3b Mon Sep 17 00:00:00 2001 From: jade <101148768+jadeddelta@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:29:16 -0400 Subject: [PATCH 26/26] relativize example paths before giving them to the renderer --- packages/autodoc/src/cli.ts | 7 +++++ packages/autodoc/src/utils.ts | 18 +++++++++++ packages/autodoc/tests/utils.test.ts | 47 +++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/autodoc/src/cli.ts b/packages/autodoc/src/cli.ts index 5ff7c5f..e3b827d 100644 --- a/packages/autodoc/src/cli.ts +++ b/packages/autodoc/src/cli.ts @@ -13,6 +13,7 @@ import { discoverSource, extractPackageJsonInfo, identifyPackageType, + relativizeExamplePaths, updateDocSections, } from "./utils.js"; import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; @@ -87,6 +88,9 @@ async function main(options: CliOptions): Promise { const exampleAnchor = anchorOrNull(); const examplePath = options.example ?? (exampleAnchor ? discoverExample(exampleAnchor) : undefined); + // example paths are rendered into the docs, so they are reported relative to the package root + const exampleRoot = exampleAnchor ?? process.cwd(); + const source = ts.createSourceFile( sourcePath, fs.readFileSync(sourcePath, "utf-8"), @@ -132,6 +136,7 @@ async function main(options: CliOptions): Promise { } extensionInfo.version = packageJsonInfo.version; + extensionInfo.examples = relativizeExamplePaths(extensionInfo.examples, exampleRoot); docs = getExtensionDocs(extensionInfo, userConfig.extension); } else if (type === "plugin") { @@ -143,6 +148,7 @@ async function main(options: CliOptions): Promise { } pluginInfo.version = packageJsonInfo.version; + pluginInfo.examples = relativizeExamplePaths(pluginInfo.examples, exampleRoot); docs = getPluginDocs(pluginInfo, userConfig.plugin); } else if (type === "timeline") { @@ -156,6 +162,7 @@ async function main(options: CliOptions): Promise { timelineInfo.name = packageJsonInfo.name; timelineInfo.description = packageJsonInfo.description; timelineInfo.version = packageJsonInfo.version; + timelineInfo.examples = relativizeExamplePaths(timelineInfo.examples, exampleRoot); docs = getTimelineDocs(timelineInfo, userConfig.timeline); } else { diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts index f30f22e..e709eec 100644 --- a/packages/autodoc/src/utils.ts +++ b/packages/autodoc/src/utils.ts @@ -2,6 +2,8 @@ import ts from "typescript"; import fs from "node:fs"; import path from "node:path"; +import { ExampleInfo } from "./types/info.js"; + /** * Updates sections of a file delimited by sentinel tags with new content from the docs object. * The docs object should have keys corresponding to section headings and thus sentinel tags in @@ -237,3 +239,19 @@ export function extractPackageJsonInfo(packageJsonPath?: string): PackageJsonInf ); } } + +/** + * returns a copy of the examples object with all paths made relative + * to the given root directory + */ +export function relativizeExamplePaths( + examples: Record, + root: string, +): Record { + return Object.fromEntries( + Object.entries(examples).map(([title, example]) => [ + title, + { ...example, path: path.relative(root, path.resolve(example.path)).split(path.sep).join("/") }, + ]), + ); +} diff --git a/packages/autodoc/tests/utils.test.ts b/packages/autodoc/tests/utils.test.ts index 6117ad3..e0b4fa7 100644 --- a/packages/autodoc/tests/utils.test.ts +++ b/packages/autodoc/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { identifyPackageType } from "../src/utils.js"; +import { identifyPackageType, relativizeExamplePaths } from "../src/utils.js"; import ts from "typescript"; import fs from "node:fs"; import path from "node:path"; @@ -53,3 +53,48 @@ describe("identifyPackageType", () => { ); }); }); + +describe("relativizeExamplePaths", () => { + const root = path.resolve("/pkg/root"); + const example = (p: string) => ({ "Example": { path: p, code: "const a = 1;" } }); + + it("rewrites an absolute path to be relative to the package root", () => { + const result = relativizeExamplePaths(example(path.join(root, "examples", "demo.html")), root); + expect(result["Example"].path).toBe("examples/demo.html"); + }); + + it("keeps ../ prefixes for examples outside the package root", () => { + const outside = path.resolve("/pkg/shared/examples/demo.html"); + const result = relativizeExamplePaths(example(outside), root); + expect(result["Example"].path).toBe("../shared/examples/demo.html"); + }); + + it("normalizes a path that is already relative to the package root", () => { + const result = relativizeExamplePaths(example("./examples/demo.html"), process.cwd()); + expect(result["Example"].path).toBe("examples/demo.html"); + }); + + it("does not mutate the input", () => { + const absolute = path.join(root, "examples", "demo.html"); + const input = example(absolute); + const result = relativizeExamplePaths(input, root); + expect(input["Example"].path).toBe(absolute); + expect(result["Example"]).not.toBe(input["Example"]); + }); + + it("preserves titles and code while rewriting every entry", () => { + const input = { + "First": { path: path.join(root, "examples", "one.html"), code: "one();" }, + "Second": { path: path.join(root, "examples", "nested", "two.html"), code: "two();" }, + }; + const result = relativizeExamplePaths(input, root); + expect(result).toEqual({ + "First": { path: "examples/one.html", code: "one();" }, + "Second": { path: "examples/nested/two.html", code: "two();" }, + }); + }); + + it("returns an empty record when there are no examples", () => { + expect(relativizeExamplePaths({}, root)).toEqual({}); + }); +});