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/.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/.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..bb5e3ed 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, @@ -2151,57 +2546,10 @@ "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" + "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, @@ -9575,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", @@ -9586,6 +9901,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 +10280,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 +10486,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/autodoc": { + "name": "@jspsych/autodoc", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "typescript": "^5.9.0" + }, + "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" + }, + "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 +10567,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 +10599,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/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/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..4c018b1 --- /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: '/tests/tsconfig.json' }], + }, +}; diff --git a/packages/autodoc/package.json b/packages/autodoc/package.json new file mode 100644 index 0000000..25775d9 --- /dev/null +++ b/packages/autodoc/package.json @@ -0,0 +1,48 @@ +{ + "name": "@jspsych/autodoc", + "version": "0.0.1", + "description": "CLI tool to generate documentation for jsPsych plugins", + "type": "module", + "bin": "./dist/cli.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "templates" + ], + "scripts": { + "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", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "jspsych", + "psychology", + "documentation", + "cli" + ], + "author": "jade", + "license": "MIT", + "dependencies": { + "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" + }, + "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..e3b827d --- /dev/null +++ b/packages/autodoc/src/cli.ts @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +import { Command } from "commander"; + +import { + discoverDest, + discoverExample, + discoverSource, + extractPackageJsonInfo, + identifyPackageType, + relativizeExamplePaths, + updateDocSections, +} from "./utils.js"; +import { getPluginInfo, getPluginInfoAndExamples } from "./parsers/plugin.js"; +import { getPluginDocs } from "./renderers/plugin.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 { AutodocConfig, ExtensionInfo, PluginInfo, TimelineInfo } 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"), +) as { version: string }; +const { version } = packageJson; + +interface CliOptions { + source?: string; + dest?: string; + example?: string; + packageJson?: string; + dryRun?: boolean; + config?: string; +} + +async function loadConfig(configPath: string | undefined): Promise { + if (!configPath) return {}; + const resolved = path.resolve(configPath); + if (!fs.existsSync(resolved)) { + throw new Error(`Config file not found at ${resolved}.`); + } + const mod = (await import(pathToFileURL(resolved).href)) as { default?: AutodocConfig }; + return mod.default ?? {}; +} + +// TODO: simulation mode-- detect if simulation mode is supported via these plugins. +async function main(options: CliOptions): Promise { + const userConfig = await loadConfig(options.config); + + 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"); + console.log(sourcePath); + + // 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); + + // 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"), + ts.ScriptTarget.Latest, + true, + ); + + const { mainNode, type } = identifyPackageType(source); + 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.config) { + const overrides = Object.keys(userConfig).filter( + (k) => Array.isArray((userConfig as Record)[k]), + ); + console.log( + ` config ${options.config} (overrides: ${overrides.length ? overrides.join(", ") : "none"})`, + ); + } + + if (options.dryRun) { + console.log("\n--dry-run: no files written."); + return; + } + + let docs: Record; + + if (type === "extension") { + let extensionInfo: ExtensionInfo; + if (examplePath) { + extensionInfo = getExtensionInfoAndExamples(source, mainNode as ts.ClassDeclaration, examplePath); + } else { + extensionInfo = getExtensionInfo(source, mainNode as ts.ClassDeclaration); + } + + extensionInfo.version = packageJsonInfo.version; + extensionInfo.examples = relativizeExamplePaths(extensionInfo.examples, exampleRoot); + + docs = getExtensionDocs(extensionInfo, userConfig.extension); + } else if (type === "plugin") { + let pluginInfo: PluginInfo; + if (examplePath) { + pluginInfo = getPluginInfoAndExamples(source, mainNode as ts.ClassDeclaration, examplePath); + } else { + pluginInfo = getPluginInfo(source, mainNode as ts.ClassDeclaration); + } + + pluginInfo.version = packageJsonInfo.version; + pluginInfo.examples = relativizeExamplePaths(pluginInfo.examples, exampleRoot); + + docs = getPluginDocs(pluginInfo, userConfig.plugin); + } else if (type === "timeline") { + let timelineInfo: TimelineInfo; + if (examplePath) { + timelineInfo = getTimelineInfoAndExamples(sourcePath, examplePath); + } else { + timelineInfo = getTimelineInfo(sourcePath); + } + + timelineInfo.name = packageJsonInfo.name; + timelineInfo.description = packageJsonInfo.description; + timelineInfo.version = packageJsonInfo.version; + timelineInfo.examples = relativizeExamplePaths(timelineInfo.examples, exampleRoot); + + docs = getTimelineDocs(timelineInfo, userConfig.timeline); + } else { + throw new Error("Unrecognized package type."); + } + + const rawContent = Object.values(docs).join("\n\n"); + if (!fs.existsSync(destPath)) { + fs.writeFileSync(destPath, rawContent, "utf8"); + } else { + const existingContent = fs.readFileSync(destPath, "utf8"); + if (existingContent.trim() === "") { + fs.writeFileSync(destPath, rawContent, "utf8"); + } else { + const updatedContent = updateDocSections(existingContent, docs); + fs.writeFileSync(destPath, 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 (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, auto-detected from examples/)") + .option( + "--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( + "--config ", + "Path to a JS config module whose default export provides custom section templates that fully replace the defaults (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 # 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/ + $ autodoc --config autodoc.config.js # custom section templates`, + ); + +program.parse(); +const options = program.opts(); +main(options).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/packages/autodoc/src/index.ts b/packages/autodoc/src/index.ts new file mode 100644 index 0000000..10f8eff --- /dev/null +++ b/packages/autodoc/src/index.ts @@ -0,0 +1,19 @@ +export { getPluginInfo } from "./parsers/plugin.js"; +export { getExtensionInfo } from "./parsers/extension.js"; +export { getTimelineInfo } from "./parsers/timeline.js"; + +export { getPluginDocs, defaultPluginTemplate } from "./renderers/plugin.js"; +export { getExtensionDocs, defaultExtensionTemplate } from "./renderers/extension.js"; +export { getTimelineDocs, defaultTimelineTemplate } from "./renderers/timeline.js"; + +export type { + AutodocConfig, + SectionTemplate, + PluginInfo, + ExtensionInfo, + TimelineInfo, + TimelineHelperInfo, + TimelineInterfaceInfo, + ParameterInfo, + ExampleInfo, +} from "./types/info.js"; diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts new file mode 100644 index 0000000..beec7f8 --- /dev/null +++ b/packages/autodoc/src/parsers/extension.ts @@ -0,0 +1,219 @@ +import ts from "typescript"; +import { ExtensionInfo } from "../types/info.js"; +import { + EXTENSION_LIFECYCLE_METHODS, + collectClassFunctions, + collectExamples, + dedent, + extractJsDocComment, + parseParamGroup, + parseTSParamGroup, +} from "./utils.js"; + +export function getExtensionInfo( + source: ts.SourceFile, + classNode: ts.ClassDeclaration, +): ExtensionInfo { + let result: ExtensionInfo = { + name: "", + description: "", + version: "", + initializeParameters: {}, + onStartParameters: {}, + onLoadParameters: {}, + onFinishParameters: {}, + data: {}, + functions: {}, + 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 = { + 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) = parseTSParamGroup(statement, source); + } + } + } + + result.functions = collectClassFunctions(classNode, source, EXTENSION_LIFECYCLE_METHODS); + + return result; +} + +/** + * 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 + * (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; + 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+)?$/; + 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; + + 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; + initJsPsychHasExtensions = init.arguments.some(hasExtensionsProperty); + } + } else if (init && (trialPattern.test(node.name.text) || hasExtensionsProperty(init))) { + trialNodes.push(node); + } + } + ts.forEachChild(node, visitNodes); + } + visitNodes(sourceFile); + + if (!initStatement) + throw new Error( + `${sourcePath}: no initJsPsych call found — use jspsych-autodoc:start/end sentinels instead`, + ); + + if (trialNodes.length === 0) + throw new Error( + 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 + 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); + if (initJsPsychVarName) localDecls.delete(initJsPsychVarName); + for (const trial of trialNodes) localDecls.delete((trial.name as ts.Identifier).text); + + 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)); + } + + const outputStatements = new Map(); + 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(); + 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 new file mode 100644 index 0000000..8f5e329 --- /dev/null +++ b/packages/autodoc/src/parsers/plugin.ts @@ -0,0 +1,189 @@ +import ts from "typescript"; +import { PluginInfo } from "../types/info.js"; +import { + PLUGIN_LIFECYCLE_METHODS, + collectClassFunctions, + collectExamples, + dedent, + extractJsDocComment, + parseParamGroup, +} from "./utils.js"; + +/** + * 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 + * @param classNode the node representing the class declaration of the plugin + * @returns a PluginInfo object containing name, description, parameters, and data. + */ +export function getPluginInfo(source: ts.SourceFile, classNode: ts.ClassDeclaration): PluginInfo { + let result: PluginInfo = { + name: "", + description: "", + version: "", + parameters: {}, + data: {}, + functions: {}, + 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); + + 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); + } + + result.functions = collectClassFunctions(classNode, source, PLUGIN_LIFECYCLE_METHODS); + + 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) => dedent(node.getFullText(sourceFile))) + .join("\n\n"); +} + +/** + * 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 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 function getPluginInfoAndExamples( + source: ts.SourceFile, + classNode: ts.ClassDeclaration, + examplePath: string, +): PluginInfo { + const info = getPluginInfo(source, classNode); + info.examples = collectExamples(examplePath, inferCodeBlock); + return info; +} diff --git a/packages/autodoc/src/parsers/timeline.ts b/packages/autodoc/src/parsers/timeline.ts new file mode 100644 index 0000000..f088228 --- /dev/null +++ b/packages/autodoc/src/parsers/timeline.ts @@ -0,0 +1,289 @@ +import ts from "typescript"; +import { TimelineInfo, TimelineHelperInfo, ParameterInfo } from "../types/info.js"; +import { + HoistEntry, + UsageMap, + buildInterfaceMap, + collectExamples, + dedent, + extractJsDocComment, + hoistKeyForType, + parseFunctionParams, + parseInterfaceMembers, + parseValueShapeMembers, +} from "./utils.js"; + +// --- FUNCTION PARSING --- + +function parseHelperFunction( + funcNode: ts.FunctionDeclaration, + source: ts.SourceFile, + interfaceMap: Map, + usageMap: UsageMap, +): TimelineHelperInfo { + return { + description: extractJsDocComment(funcNode, source) ?? "", + helperParameters: parseFunctionParams(funcNode, 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}`); +} + +/** + * recursively rewrites every `ParameterInfo` whose type was hoisted: its `nested` + * expansion is dropped and replaced with an `interfaceRef`. Walking the tree (rather + * than only the directly-recorded param sites) means a hoisted type also collapses + * to a ref where it appears nested inside another type's fields. + */ +function refHoistedTypes(params: Record, hoisted: Set): void { + for (const info of Object.values(params)) { + if (!info.nested) continue; + const key = hoistKeyForType(info.type); + if (hoisted.has(key)) { + delete info.nested; + info.interfaceRef = key; + } else { + refHoistedTypes(info.nested, hoisted); + } + } +} + +/** + * modifies `result` to hoist interfaces (and `typeof` value-shapes) used directly as + * a parameter in 2 or more timeline functions into the shared section, then replaces + * every occurrence of a hoisted type -- including nested ones -- with an `interfaceRef`. + */ +function hoistSharedInterfaces( + result: TimelineInfo, + usageMap: UsageMap, + interfaceMap: Map, + source: ts.SourceFile, +): void { + const hoisted = new Set(); + for (const [name, sites] of usageMap) { + if (sites.length >= 2 && interfaceMap.has(name)) hoisted.add(name); + } + + // build each shared section first, so nested refs (applied below) resolve to it + for (const name of hoisted) { + const entry = interfaceMap.get(name)!; + const interfaceParameters = + entry.kind === "interface" + ? parseInterfaceMembers(entry, undefined, source, interfaceMap, new Set([name]), usageMap) + : parseValueShapeMembers(entry.objLiteral, entry.source); + result.interfaces[name] = { + description: extractJsDocComment(entry.decl, entry.source) ?? "", + interfaceParameters, + }; + } + + // rewrite every site (params and the shared sections themselves) in one tree walk + const allParams = [ + result.createTimeline.helperParameters, + ...Object.values(result.timelineUnits).map((u) => u.helperParameters), + ...Object.values(result.utils).map((u) => u.helperParameters), + ...Object.values(result.interfaces).map((i) => i.interfaceParameters), + ]; + for (const params of allParams) refHoistedTypes(params, hoisted); +} + +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; +} + +/** + * 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(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 new file mode 100644 index 0000000..0320844 --- /dev/null +++ b/packages/autodoc/src/parsers/utils.ts @@ -0,0 +1,906 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { ExampleInfo, FunctionInfo, ParameterInfo, ReturnInfo } from "../types/info.js"; +import { PARAMETER_TYPE_MAP } from "../renderers/utils.js"; + +// --- PARSE SRC FILE UTILS --- + +/** thing that removes comments from nested parameter types */ +export const printer = ts.createPrinter({ removeComments: true }); + +/** 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(); +} + +/** + * 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, + 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)) { + const raw = prop.initializer.getText(source); + result.type = PARAMETER_TYPE_MAP[raw] ?? raw; + } + break; + } + case "default": { + result.default = wrapBackticks(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 { + const result: Record = {}; + 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 { + const result: Record = {}; + 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 = {}; + + 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; +} + +// --- 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 . */ +export function getExampleInfo( + sourcePath: string, + inferFallback: (content: string, path: string) => 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, 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; +} + +// --- 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<string, ParameterInfo[]>; + +function recordInterfaceUsage( + usageMap: UsageMap, + interfaceMap: Map<string, HoistEntry>, + 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<string, HoistEntry> { + const map = new Map<string, HoistEntry>(); + 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<string, string> { + const result: Record<string, string> = {}; + 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<string, HoistEntry>, + visited: Set<string>, + usageMap: UsageMap, +): ParameterInfo { + const info: Partial<ParameterInfo> = {}; + 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<any>`, `Map<string, number>`): 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<string, HoistEntry>, + visited: Set<string>, + usageMap: UsageMap, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + 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<string, HoistEntry>, + visited: Set<string>, + usageMap: UsageMap, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + 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<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + 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<string, string>, + identifierParamNames: Set<string>, + existing: Record<string, ParameterInfo>, +): { 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<string, HoistEntry>, + 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<string, HoistEntry>, + usageMap: UsageMap, +): Record<string, ParameterInfo> { + const result: Record<string, ParameterInfo> = {}; + 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<string, HoistEntry>, + 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<string, HoistEntry>, + 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<string>, +): Record<string, FunctionInfo> { + const interfaceMap = buildInterfaceMap(source); + const usageMap: UsageMap = new Map(); + const result: Record<string, FunctionInfo> = {}; + 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 new file mode 100644 index 0000000..1b2917f --- /dev/null +++ b/packages/autodoc/src/renderers/extension.ts @@ -0,0 +1,123 @@ +import { ExtensionInfo, SectionTemplate } from "../types/info.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, removePackageName, renderSections, topParameterChart, topDataChart } from "./utils.js"; + +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(""); + +export const defaultExtensionTemplate: SectionTemplate<ExtensionInfo>[] = [ + { + heading: "introduction", + render: (info) => { + return `# ${info.name} + +${removePackageName(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(); + }, + }, + { + 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. + +${renderFunctionGroup(info.functions)} +`.trim(); + }, + }, + { + heading: "examples", + render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; + 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, + template: SectionTemplate<ExtensionInfo>[] = defaultExtensionTemplate, +): Record<string, string> { + 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 new file mode 100644 index 0000000..9ac4a4d --- /dev/null +++ b/packages/autodoc/src/renderers/plugin.ts @@ -0,0 +1,91 @@ +import { PluginInfo, SectionTemplate } from "../types/info.js"; +import { renderParameterRow, renderDataRow, renderFunctionGroup, removePackageName, renderSections, topParameterChart, topDataChart, PARAMETER_TYPE_MAP } from "./utils.js"; + +const stringifyTypeMap = PARAMETER_TYPE_MAP; + +const getTypeName = (type: string, array?: boolean): string => { + const baseType = stringifyTypeMap[type] || type; + return array ? `array of ${baseType}` : baseType; +}; + +export const defaultPluginTemplate: SectionTemplate<PluginInfo>[] = [ + { + heading: "introduction", + render: (info) => { + return `# ${info.name} + +${removePackageName(info.description)} + +Current version: ${info.version}`.trim(); + }, + }, + { + heading: "parameters", + render: (info) => { + const rows = Object.entries(info.parameters) + .map(([name, param]) => renderParameterRow(name, param, getTypeName)) + .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 ?? "*None*"} +`.trim(); + }, + }, + { + heading: "data", + render: (info) => { + const rows = Object.entries(info.data) + .map(([name, param]) => renderDataRow(name, param, getTypeName)) + .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 ?? "*None*"} +`.trim(); + }, + }, + { + 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. + +${renderFunctionGroup(info.functions)} +`.trim(); + }, + }, + { + heading: "examples", + render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; + const sections = Object.entries(info.examples) + .map( + ([title, example]) => + `### ${title} (${example.path}) + +\`\`\`js +${example.code} +\`\`\``, + ) + .join("\n\n"); + return `## Examples + +${sections} +`.trim(); + }, + }, +]; + +export function getPluginDocs( + info: PluginInfo, + template: SectionTemplate<PluginInfo>[] = defaultPluginTemplate, +): Record<string, string> { + return renderSections(info, template); +} diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts new file mode 100644 index 0000000..e47000a --- /dev/null +++ b/packages/autodoc/src/renderers/timeline.ts @@ -0,0 +1,148 @@ +import { SectionTemplate, TimelineInfo, TimelineHelperInfo, TimelineInterfaceInfo, ParameterInfo } from "../types/info.js"; +import { removePackageName, renderSections, 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<string, ParameterInfo>, + 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, TimelineHelperInfo>): string { + const sections = Object.entries(group) + .map(([name, helper]) => `#### \`${name}()\`\n\n${renderFunctionBody(helper, "#####")}`) + .join("\n\n"); + return sections || "*None*"; +} + +export const defaultTimelineTemplate: SectionTemplate<TimelineInfo>[] = [ + { + heading: "introduction", + render: (info) => { + return `# ${info.name} + +${removePackageName(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) => { + 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.*"; + 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}`; + }, + }, + { + heading: "examples", + render: (info) => { + if (Object.keys(info.examples).length === 0) return ""; + 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, + template: SectionTemplate<TimelineInfo>[] = defaultTimelineTemplate, +): Record<string, string> { + return renderSections(info, template); +} diff --git a/packages/autodoc/src/renderers/utils.ts b/packages/autodoc/src/renderers/utils.ts new file mode 100644 index 0000000..1cc2c5c --- /dev/null +++ b/packages/autodoc/src/renderers/utils.ts @@ -0,0 +1,148 @@ +import { FunctionInfo, 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<T>( + info: T, + template: SectionTemplate<T>[], +): Record<string, string> { + return Object.fromEntries( + template.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]; + }), + ); +} + +/** + * 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<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", + "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 | +| --------- | ---- | ------------- | ----------- |`; + +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} |`; +}; + +/** 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, FunctionInfo>): 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 new file mode 100644 index 0000000..3f8526d --- /dev/null +++ b/packages/autodoc/src/types/info.ts @@ -0,0 +1,92 @@ +export interface PluginInfo { + name: string; + description: string; + version: string; // gathered from package.json + parameters: Record<string, ParameterInfo>; + data: Record<string, ParameterInfo>; + /** public (static or instance) helper methods, excluding the required `trial`/`simulate` lifecycle methods */ + functions: Record<string, FunctionInfo>; + examples: Record<string, ExampleInfo>; +} + +export interface ExtensionInfo { + name: string; + description: string; + version: string; // gathered from package.json + initializeParameters: Record<string, ParameterInfo>; + onStartParameters: Record<string, ParameterInfo>; + onLoadParameters: Record<string, ParameterInfo>; + onFinishParameters: Record<string, ParameterInfo>; + data: Record<string, ParameterInfo>; + /** public (static or instance) helper methods, excluding the required `initialize`/`on_start`/`on_load`/`on_finish` lifecycle methods */ + functions: Record<string, FunctionInfo>; + examples: Record<string, ExampleInfo>; +} + +export interface TimelineInfo { + 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<string, TimelineHelperInfo>; + utils: Record<string, TimelineHelperInfo>; + /** common interfaces used by 2 or more functions */ + interfaces: Record<string, TimelineInterfaceInfo>; + examples: Record<string, ExampleInfo>; +} + +/** name is attached via record */ +export interface TimelineInterfaceInfo { + description: string; + interfaceParameters: Record<string, ParameterInfo>; +} + +/** name is attached via record */ +export interface TimelineHelperInfo { + description: string; + helperParameters: Record<string, ParameterInfo>; +} + +/** name is attached via record */ +export interface FunctionInfo { + description: string; + isStatic: boolean; + parameters: Record<string, ParameterInfo>; + /** 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; + default: string; + array?: boolean; + description?: string; + nested?: Record<string, ParameterInfo>; + /** used instead of `nested` if an interface is used across more than 2 functions (for timeline parsing) */ + interfaceRef?: string; +} + +/** name is attached via record */ +export interface ExampleInfo { + path: string; + code: string; +} + +export interface SectionTemplate<T> { + heading: string; + render: (info: T) => string; +} + +export interface AutodocConfig { + plugin?: SectionTemplate<PluginInfo>[]; + extension?: SectionTemplate<ExtensionInfo>[]; + timeline?: SectionTemplate<TimelineInfo>[]; +} diff --git a/packages/autodoc/src/utils.ts b/packages/autodoc/src/utils.ts new file mode 100644 index 0000000..e709eec --- /dev/null +++ b/packages/autodoc/src/utils.ts @@ -0,0 +1,257 @@ +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 + * 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; +} + +/** + * Identifies whether a source file contains a plugin/extension/timeline, + * returning the classNode and the type. classNode here represents the "main node", either + * the class node that implements `jsPsychPlugin`/`jsPsychExtension`, or the function node that + * is the createTimeline entrypoint for jsPsych timelines. + * + * @param source the AST of the source file + * @returns object containing the "main node" (for use in extracting doc, so that + * getXXXInfo does not have to re-find) and the type of package (plugin/extension/timeline). + */ +export function identifyPackageType(source: ts.SourceFile): { + mainNode: ts.ClassDeclaration | ts.FunctionDeclaration; + type: "plugin" | "extension" | "timeline"; +} { + let result: { + mainNode: ts.ClassDeclaration | ts.FunctionDeclaration; + type: "plugin" | "extension" | "timeline" + } | null = null; + + function searchForMainNode(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 = { 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, searchForMainNode); + } + searchForMainNode(source); + + if (!result) { + throw new Error("No plugin or extension class found in source file."); + } + + return result; +} + +/** + * attempts to find the source file (src/index.ts, then index.ts), but fails if not found. + */ +export function discoverSource(anchor: string): string { + const candidates = [path.join(anchor, "src", "index.ts"), path.join(anchor, "index.ts")]; + const found = candidates.find((p) => 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>): 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 (`<type>-<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; + 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 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) { + 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)})`, + ); + } +} + +/** + * returns a copy of the examples object with all paths made relative + * to the given root directory + */ +export function relativizeExamplePaths( + examples: Record<string, ExampleInfo>, + root: string, +): Record<string, ExampleInfo> { + 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/cli.test.ts b/packages/autodoc/tests/cli.test.ts new file mode 100644 index 0000000..a204494 --- /dev/null +++ b/packages/autodoc/tests/cli.test.ts @@ -0,0 +1,152 @@ +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"); + +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, string>): string => { + const root = makeTree(spec); + roots.push(root); + return root; +}; +afterEach(() => { + roots.splice(0).forEach(removeTree); +}); + +interface CliResult { + status: number; + stdout: string; + stderr: string; +} + +/** 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<string, unknown> = {}) => + JSON.stringify({ + name: "@jspsych/plugin-test", + description: "A test plugin", + version: "9.9.9", + ...overrides, + }); + +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": "", + }); + + const { status, stdout } = runCli(["--dry-run"], root); + + 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 + + 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("<!-- jspsych-autodocs:"); + expect(written).toContain("test-plugin"); // name from the source's info object + expect(written).toContain("9.9.9"); // version sourced from package.json + }); +}); diff --git a/packages/autodoc/tests/discovery.test.ts b/packages/autodoc/tests/discovery.test.ts new file mode 100644 index 0000000..e5e9b64 --- /dev/null +++ b/packages/autodoc/tests/discovery.test.ts @@ -0,0 +1,146 @@ +import path from "node:path"; + +import { jest } from "@jest/globals"; + +import { + discoverDest, + discoverExample, + discoverSource, + extractPackageJsonInfo, +} from "../src/utils.js"; +import { makeTree, removeTree } from "./helpers/tempTree.js"; + +const roots: string[] = []; +const tree = (spec: Record<string, string>): string => { + const root = makeTree(spec); + roots.push(root); + return root; +}; +afterEach(() => { + roots.splice(0).forEach(removeTree); +}); + +describe("discoverSource", () => { + it("prefers src/index.ts over index.ts", () => { + const root = tree({ "src/index.ts": "", "index.ts": "" }); + expect(discoverSource(root)).toBe(path.join(root, "src", "index.ts")); + }); + + it("falls back to index.ts when src/index.ts is absent", () => { + const root = tree({ "index.ts": "" }); + expect(discoverSource(root)).toBe(path.join(root, "index.ts")); + }); + + it("throws when neither candidate exists", () => { + const root = tree({ "README.md": "" }); + expect(() => discoverSource(root)).toThrow("Could not auto-detect a source file"); + }); +}); + +describe("discoverExample", () => { + it("returns the examples/ directory when present", () => { + const root = tree({ "examples/demo.html": "" }); + expect(discoverExample(root)).toBe(path.join(root, "examples")); + }); + + it("returns undefined when examples/ is absent", () => { + const root = tree({ "src/index.ts": "" }); + expect(discoverExample(root)).toBeUndefined(); + }); + + it("returns undefined when 'examples' is a file, not a directory", () => { + const root = tree({ examples: "" }); + expect(discoverExample(root)).toBeUndefined(); + }); +}); + +describe("discoverDest", () => { + it("matches the type-stripped stem inside docs/", () => { + const root = tree({ "docs/foo.md": "" }); + expect(discoverDest(root, "@jspsych/plugin-foo", "plugin")).toBe( + path.join(root, "docs", "foo.md"), + ); + }); + + it("matches the full unscoped stem at the top level", () => { + const root = tree({ "plugin-foo.md": "" }); + expect(discoverDest(root, "@jspsych/plugin-foo", "plugin")).toBe( + path.join(root, "plugin-foo.md"), + ); + }); + + it("matches the reconstructed <type>-<name> stem when the name lacks a prefix", () => { + const root = tree({ "docs/plugin-foo.md": "" }); + expect(discoverDest(root, "@jspsych/foo", "plugin")).toBe( + path.join(root, "docs", "plugin-foo.md"), + ); + }); + + it("searches docs/ recursively", () => { + const root = tree({ "docs/plugins/foo.md": "" }); + expect(discoverDest(root, "@jspsych/plugin-foo", "plugin")).toBe( + path.join(root, "docs", "plugins", "foo.md"), + ); + }); + + it("ignores non-matching markdown like README.md", () => { + const root = tree({ "README.md": "", "docs/foo.md": "" }); + expect(discoverDest(root, "@jspsych/plugin-foo", "plugin")).toBe( + path.join(root, "docs", "foo.md"), + ); + }); + + it("throws when no candidate exists", () => { + const root = tree({ "README.md": "" }); + expect(() => discoverDest(root, "@jspsych/plugin-foo", "plugin")).toThrow( + "Could not find an existing docs file", + ); + }); + + it("throws when multiple candidates exist", () => { + const root = tree({ "plugin-foo.md": "", "docs/foo.md": "" }); + expect(() => discoverDest(root, "@jspsych/plugin-foo", "plugin")).toThrow("Multiple candidate"); + }); +}); + +describe("extractPackageJsonInfo", () => { + it("reads name, description, and version", () => { + const root = tree({ + "package.json": JSON.stringify({ + name: "@jspsych/plugin-foo", + description: "A foo plugin", + version: "2.1.0", + }), + }); + expect(extractPackageJsonInfo(path.join(root, "package.json"))).toEqual({ + name: "@jspsych/plugin-foo", + description: "A foo plugin", + version: "2.1.0", + }); + }); + + it("falls back to placeholders for missing fields", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const root = tree({ "package.json": JSON.stringify({ name: "@jspsych/plugin-foo" }) }); + expect(extractPackageJsonInfo(path.join(root, "package.json"))).toEqual({ + name: "@jspsych/plugin-foo", + description: "unknown description", + version: "unknown version", + }); + warn.mockRestore(); + }); + + it("throws on malformed JSON", () => { + const root = tree({ "package.json": "{ not valid json" }); + expect(() => extractPackageJsonInfo(path.join(root, "package.json"))).toThrow( + "Could not read package.json", + ); + }); + + it("throws when the file does not exist", () => { + const root = tree({}); + expect(() => extractPackageJsonInfo(path.join(root, "package.json"))).toThrow( + "Could not read package.json", + ); + }); +}); diff --git a/packages/autodoc/tests/fixtures/extension/basic.ts b/packages/autodoc/tests/fixtures/extension/basic.ts new file mode 100644 index 0000000..8b0561f --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/basic.ts @@ -0,0 +1,116 @@ +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, + }, + }, + }, + }, + }; + + // initialize is a lifecycle method and must be excluded from the docs + initialize(_params: InitializeParameters): Promise<void> { + 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/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/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/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 @@ + + + + + + + + + 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/fixtures/plugin/basic.ts b/packages/autodoc/tests/fixtures/plugin/basic.ts new file mode 100644 index 0000000..220d796 --- /dev/null +++ b/packages/autodoc/tests/fixtures/plugin/basic.ts @@ -0,0 +1,109 @@ +// import { JsPsych ... } + +// import { version } ... + +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 = { + 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, + }, + /** A prompt rendered as HTML. */ + prompt: { + type: ParameterType.HTML_STRING, + default: `

string

`, + }, + /** 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 @@ + + + + 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..a524562 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/basic.ts @@ -0,0 +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) { + +} + +/** + * doohickeyinator + */ +function createStimulus({ + stimuli, duration, reverse +}: { + stimuli: string[], + duration: Array, + reverse: boolean +}) { + +} + +/** + * 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 notShown() { + +} + +/** + * 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 = { + createFixation, + createStimulus, + createFeedbackTrial +} + +export const utils = { + canShowCreature +} diff --git a/packages/autodoc/tests/fixtures/timeline/destructured.ts b/packages/autodoc/tests/fixtures/timeline/destructured.ts new file mode 100644 index 0000000..2da9b36 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/destructured.ts @@ -0,0 +1,44 @@ +import { JsPsych } from "../../utils.js"; + +/** object of UI strings used across the task */ +export const trial_text = { + next: "Next", + count: 3, +}; + +/** shared config nation */ +interface SharedConfig { + /** whether to show the thing */ + show?: boolean; + /** the text bag */ + text_object?: typeof trial_text; +} + +/** + * builds the timeline + * @param jsPsych the active instance + * @param config tuning knobs + */ +export function createTimeline( + jsPsych: JsPsych, + { show = false, text_object = trial_text }: SharedConfig = {}, +) {} + +/** advanced practice section */ +function createPractice({ show, text_object }: SharedConfig = {}) {} + +/** + * standalone widget configured with its own inline options bag + */ +function createWidget({ + label = "ok", + repeat = 2, +}: { + /** button label */ + label?: string; + /** how many times */ + repeat?: number; +}) {} + +export const timelineUnits = { createPractice, createWidget }; +export const utils = {}; 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/fixtures/timeline/typeof-hoist.ts b/packages/autodoc/tests/fixtures/timeline/typeof-hoist.ts new file mode 100644 index 0000000..eb5ced9 --- /dev/null +++ b/packages/autodoc/tests/fixtures/timeline/typeof-hoist.ts @@ -0,0 +1,34 @@ +import { JsPsych } from "../../utils.js"; + +/** the text bag shared across the task */ +export const trial_text = { + /** the go-ahead label */ + next_button: "Next", + pages: ["a", "b"], + format: (n: number) => `${n}`, +}; + +/** + * shows instructions + * @param text the strings to render + */ +function createInstructions(text: typeof trial_text = trial_text) {} + +/** + * shows the debrief + * @param text the strings to render + */ +function createDebrief(text: typeof trial_text = trial_text) {} + +/** + * builds the timeline + * @param jsPsych the active instance + * @param config the knobs, including a nested typeof trial_text + */ +export function createTimeline( + jsPsych: JsPsych, + { text_object = trial_text }: { text_object?: typeof trial_text } = {}, +) {} + +export const timelineUnits = { createInstructions, createDebrief }; +export const utils = { trial_text }; 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, 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/helpers/tempTree.ts b/packages/autodoc/tests/helpers/tempTree.ts new file mode 100644 index 0000000..f59b8ea --- /dev/null +++ b/packages/autodoc/tests/helpers/tempTree.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * builds a dummy directory under the OS temp dir, returning the root. keys for + * `spec` are the file paths, relative to the root, and the values are the + * contents of the file. we need this to temporarily create `package.json`s + * to mimic real file systems. + * + * we need this to allow discovery tests and e2e CLI testing to run, without + * incurring an error from `jest-haste-map`'s duplicate-package-name detection. + */ +export function makeTree(spec: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "autodoc-test-")); + for (const [rel, content] of Object.entries(spec)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return root; +} + +/** removes a tree created by {@link makeTree} */ +export function removeTree(root: string): void { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts new file mode 100644 index 0000000..517ac33 --- /dev/null +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -0,0 +1,255 @@ +import { getExtensionInfo, getExtensionInfoAndExamples } 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 { 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 { 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 { 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'); + 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 { 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 { 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 { 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(); + 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 { 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.'); + expect(info.onFinishParameters.boolean_param.default).toBe('[[true, false], [false, true]]'); + }); + + it('extracts data parameters', () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getExtensionInfo(fixtureSource, classNode as ts.ClassDeclaration); + 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('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('object'); + expect(info.data.grid.description).toBe("Now let's have a grid."); + expect(info.data.grid.nested).toBeDefined(); + }); +}); + +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; + + 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'); + + it('should extract examples from a provided file', () => { + const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); + 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); + 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 { 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(); + + 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};' + ); + }); +}); diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts new file mode 100644 index 0000000..37f3658 --- /dev/null +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -0,0 +1,164 @@ +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'; +import { identifyPackageType } from '../../src/utils.js'; + +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', () => { + 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 { 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 { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); + expect(info.parameters.single.type).toBe('string'); + expect(info.parameters.single.description).toBe('Single-line description.'); + 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.'); + }); + + 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); + expect(info.parameters.list_of_stimuli.array).toBe(true); + }); + + it('extracts data parameters', () => { + const { mainNode: classNode } = identifyPackageType(fixtureSource); + const info = getPluginInfo(fixtureSource, classNode as ts.ClassDeclaration); + 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('boolean'); + expect(info.data.double_data.description).toBe('Multi-line data parameter description. It has two lines for data.'); + }); +}); + +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'); + + it('should extract examples from provided file', () => { + const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); + 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); + expect(info.examples['simple sentinel example'].code).toBe( + 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello"\n};' + ); + }); + + it('should extract examples from provided directory', () => { + 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(); + + 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/parsers/timeline.test.ts b/packages/autodoc/tests/parsers/timeline.test.ts new file mode 100644 index 0000000..37e18b7 --- /dev/null +++ b/packages/autodoc/tests/parsers/timeline.test.ts @@ -0,0 +1,213 @@ +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'); +const destructuredFixturePath = path.resolve(__dirname, '../fixtures/timeline/destructured.ts'); +const typeofHoistFixturePath = path.resolve(__dirname, '../fixtures/timeline/typeof-hoist.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('getTimelineInfo with destructured parameters', () => { + it('parses a destructured object parameter instead of dropping it', () => { + const info = getTimelineInfo(destructuredFixturePath); + const config = info.createTimeline.helperParameters.config; + expect(config).toBeDefined(); + expect(config.type).toBe('SharedConfig'); + expect(config.default).toBe('{}'); + }); + + it('names a destructured parameter from its leftover JSDoc @param tag', () => { + const info = getTimelineInfo(destructuredFixturePath); + expect(info.createTimeline.helperParameters.config.description).toBe('tuning knobs'); + }); + + it('falls back to "config" when a destructured parameter has no JSDoc tag', () => { + const info = getTimelineInfo(destructuredFixturePath); + expect(info.timelineUnits.createWidget.helperParameters.config).toBeDefined(); + }); + + it('hoists a config interface shared by destructured parameters across functions', () => { + const info = getTimelineInfo(destructuredFixturePath); + // SharedConfig is the type of the destructured param in both createTimeline and createPractice + expect(info.interfaces.SharedConfig).toBeDefined(); + expect(info.interfaces.SharedConfig.description).toBe('shared config nation'); + expect(info.interfaces.SharedConfig.interfaceParameters.show.type).toBe('boolean'); + + expect(info.createTimeline.helperParameters.config.interfaceRef).toBe('SharedConfig'); + expect(info.createTimeline.helperParameters.config.nested).toBeUndefined(); + expect(info.timelineUnits.createPractice.helperParameters.config.interfaceRef).toBe('SharedConfig'); + expect(info.timelineUnits.createPractice.helperParameters.config.nested).toBeUndefined(); + }); + + it('overlays per-field defaults from binding elements (non-hoisted inline literal)', () => { + const info = getTimelineInfo(destructuredFixturePath); + // createWidget uses an inline type literal, so it is not hoisted and keeps nested members + const widgetConfig = info.timelineUnits.createWidget.helperParameters.config; + expect(widgetConfig.interfaceRef).toBeUndefined(); + expect(widgetConfig.nested).toBeDefined(); + expect(widgetConfig.nested!.label.type).toBe('string'); + expect(widgetConfig.nested!.label.default).toBe('"ok"'); + expect(widgetConfig.nested!.repeat.default).toBe('2'); + // an inline object literal type is never hoisted into the shared interfaces section + expect(Object.keys(info.interfaces)).toEqual(['SharedConfig']); + }); + + it('keeps the `typeof X` label and expands the value shape inline when not hoisted', () => { + const info = getTimelineInfo(destructuredFixturePath); + // typeof trial_text is only referenced once (as a nested field), so it is not + // hoisted; its underlying object shape is expanded inline instead. + const textObject = info.interfaces.SharedConfig.interfaceParameters.text_object; + expect(textObject.type).toBe('typeof trial_text'); + expect(textObject.nested).toEqual({ + next: { type: 'string', default: '"Next"' }, + count: { type: 'number', default: '3' }, + }); + }); +}); + +describe('getTimelineInfo with `typeof` value-shape parameters', () => { + it('hoists a `typeof x` object shape used as a parameter type in 2+ functions', () => { + const info = getTimelineInfo(typeofHoistFixturePath); + expect(Object.keys(info.interfaces)).toEqual(['trial_text']); + expect(info.interfaces.trial_text.description).toBe('the text bag shared across the task'); + }); + + it('infers field types from the object values and keeps each value as the default', () => { + const info = getTimelineInfo(typeofHoistFixturePath); + const fields = info.interfaces.trial_text.interfaceParameters; + expect(fields.next_button.type).toBe('string'); + expect(fields.next_button.default).toBe('"Next"'); + expect(fields.next_button.description).toBe('the go-ahead label'); + expect(fields.pages.type).toBe('string'); + expect(fields.pages.array).toBe(true); + expect(fields.format.type).toBe('function'); + }); + + it('replaces each hoisted `typeof x` site with an interfaceRef while keeping the typeof label', () => { + const info = getTimelineInfo(typeofHoistFixturePath); + const instructionsText = info.timelineUnits.createInstructions.helperParameters.text; + expect(instructionsText.type).toBe('typeof trial_text'); + expect(instructionsText.interfaceRef).toBe('trial_text'); + expect(instructionsText.nested).toBeUndefined(); + expect(info.timelineUnits.createDebrief.helperParameters.text.interfaceRef).toBe('trial_text'); + }); + + it('collapses a hoisted type to a ref even where it appears nested inside another type', () => { + const info = getTimelineInfo(typeofHoistFixturePath); + // createTimeline's inline config has a `text_object: typeof trial_text` field; + // since trial_text is hoisted, that nested field should be a ref, not expanded inline. + const config = info.createTimeline.helperParameters.config; + expect(config.nested).toBeDefined(); + const textObject = config.nested!.text_object; + expect(textObject.type).toBe('typeof trial_text'); + expect(textObject.interfaceRef).toBe('trial_text'); + expect(textObject.nested).toBeUndefined(); + }); +}); + +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})' + ); + }); +}); 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..220e30e --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extension renderer (default template) matches the rendered snapshot 1`] = ` +{ + "data": " +## Data Generated + +| Name | Type | Value | +| ---- | ---- | ----- | +| samples | object | Collected samples. | +", + "examples": " +## Examples + +### Basic example (examples/basic.html) + +\`\`\`js +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()\`. + +\`\`\`js +initJsPsych({ + extensions: { + { type: jsPsychExtensionTestExtension, params: { ... } } + } +}) +\`\`\` + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| tracking | boolean | \`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 | 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..6f1e937 --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap @@ -0,0 +1,63 @@ +// 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 }; +\`\`\` +", + "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 + +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..751f2e7 --- /dev/null +++ b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`timeline renderer (default template) matches the rendered snapshot 1`] = ` +{ + "api-reference": " +## API Reference +", + "configuration-options": " + +", + "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..4e27975 --- /dev/null +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -0,0 +1,80 @@ +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: "boolean", default: "true", description: "Whether to track." }, + }, + onStartParameters: { + label: { type: "string", default: "undefined", description: "Trial label." }, + }, + onLoadParameters: {}, + onFinishParameters: {}, + 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: [...] });" }, + }, +}; + +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", + "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"); + }); + + 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(); + }); +}); + +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 new file mode 100644 index 0000000..4acd29d --- /dev/null +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -0,0 +1,106 @@ +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." }, + }, + 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 };" }, + }, +}; + +describe("plugin renderer (default template)", () => { + const docs = getPluginDocs(info); + + it("produces the default sections", () => { + 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", () => { + expect(docs.parameters).toContain("HTML string"); + expect(docs.parameters).toContain("integer"); + 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"); + }); + + 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(); + }); +}); + +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[] = [ + { 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..54ca60d --- /dev/null +++ b/packages/autodoc/tests/renderers/timeline.test.ts @@ -0,0 +1,59 @@ +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("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(); + }); +}); diff --git a/packages/autodoc/tests/renderers/utils.test.ts b/packages/autodoc/tests/renderers/utils.test.ts new file mode 100644 index 0000000..a7ee057 --- /dev/null +++ b/packages/autodoc/tests/renderers/utils.test.ts @@ -0,0 +1,126 @@ +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", () => { + 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"); + 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(); + }); +}); + +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 +// 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"); + }); +}); 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/tests/utils.test.ts b/packages/autodoc/tests/utils.test.ts new file mode 100644 index 0000000..e0b4fa7 --- /dev/null +++ b/packages/autodoc/tests/utils.test.ts @@ -0,0 +1,100 @@ +import { identifyPackageType, relativizeExamplePaths } 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 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"); + }); + + it("identifies extension class", () => { + const result = identifyPackageType(extensionSource); + expect(result.type).toBe("extension"); + 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." + ); + }); + + it("throws if no class found in source file", () => { + expect(() => identifyPackageType(noClassSource)).toThrow( + "No plugin or extension class found in source file." + ); + }); +}); + +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({}); + }); +}); diff --git a/packages/autodoc/tests/utils.ts b/packages/autodoc/tests/utils.ts new file mode 100644 index 0000000..d54221d --- /dev/null +++ b/packages/autodoc/tests/utils.ts @@ -0,0 +1,15 @@ +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; +} + +export interface JsPsychExtension { + +} + +export type JsPsych = { + dummy: string; +} diff --git a/packages/autodoc/tsconfig.json b/packages/autodoc/tsconfig.json new file mode 100644 index 0000000..249ed50 --- /dev/null +++ b/packages/autodoc/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["jest", "node"], + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}