From 44d17b3ebd0cbf283528c3a22405adc3f0d8e7ff Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 21:59:32 +0100 Subject: [PATCH 01/13] [OP-18657] Add frontend API documentation tooling TypeDoc renders the reference, its GitHub theme matches the surface the docs are linked from, and eslint-plugin-tsdoc keeps the doc comments parseable by the generator. --- frontend/package-lock.json | 503 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 3 + 2 files changed, 506 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 308cbefc6988..1ec35fc19264 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -173,12 +173,15 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-tsdoc": "^0.5.2", "globals": "^17.11.0", "jsdom": "^29.1.1", "patch-package": "^8.0.1", "playwright": "^1.61.1", "source-map-explorer": "^2.5.2", "ts-node": "~10.9.2", + "typedoc": "^0.28.20", + "typedoc-github-theme": "^0.4.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vitest": "^4.1.10", @@ -3541,6 +3544,31 @@ "@fullcalendar/core": "~6.1.21" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/@github/auto-check-element": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@github/auto-check-element/-/auto-check-element-6.0.0.tgz", @@ -4591,6 +4619,43 @@ "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==" }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -6102,6 +6167,70 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/engine-oniguruma/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/langs/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/@shikijs/types": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz", @@ -9824,6 +9953,211 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-tsdoc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.5.2.tgz", + "integrity": "sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@microsoft/tsdoc-config": "0.18.1", + "@typescript-eslint/utils": "~8.56.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -11859,6 +12193,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/jose": { "version": "6.2.9", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", @@ -12161,6 +12502,26 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/listr2": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", @@ -12546,6 +12907,13 @@ "es5-ext": "~0.10.2" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -12598,6 +12966,41 @@ "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.3.0.tgz", "integrity": "sha512-/K3BC0KIsO+WK2i94LkMPv3wslMrazrQhfi5We9fMbLlLjzoOSJWr7TAdupLlDWaJcWxwoNosBkhFDejiu5VDw==" }, + "node_modules/markdown-it": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.1.tgz", + "integrity": "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -12625,6 +13028,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/mdx-embed": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/mdx-embed/-/mdx-embed-1.1.2.tgz", @@ -14188,6 +14598,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qr-creator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", @@ -16020,6 +16440,82 @@ "tslib": "^2.0.1" } }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "node_modules/typedoc-github-theme": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/typedoc-github-theme/-/typedoc-github-theme-0.4.0.tgz", + "integrity": "sha512-lo/hr4EFZxq0SsMGeAscKUzljIKFgrJf5fb4nOAJcqaiSShQv7kzwF6M1s2fVRvUyx6UsmD4zEb+MtKkbucYpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typedoc": "~0.28.0" + } + }, + "node_modules/typedoc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -16057,6 +16553,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9c3af0c1272..2b649a46d555 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,12 +44,15 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-tsdoc": "^0.5.2", "globals": "^17.11.0", "jsdom": "^29.1.1", "patch-package": "^8.0.1", "playwright": "^1.61.1", "source-map-explorer": "^2.5.2", "ts-node": "~10.9.2", + "typedoc": "^0.28.20", + "typedoc-github-theme": "^0.4.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vitest": "^4.1.10", From cd7c865aef7057ca1799d653cd3db001915060b0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 21:59:32 +0100 Subject: [PATCH 02/13] [OP-18657] Generate API docs for Stimulus sources Scopes TypeDoc to the Stimulus entry points, excluding specs and the application bootstrap, so the reference describes reusable controllers, helpers and mixins rather than the whole frontend. Output is ignored rather than committed; `npm run generate-docs` builds it locally. --- frontend/.gitignore | 1 + frontend/doc/README.md | 11 +++++++++++ frontend/package.json | 1 + frontend/typedoc.json | 15 +++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 frontend/typedoc.json diff --git a/frontend/.gitignore b/frontend/.gitignore index f5a6762a0a9d..afec9f03f5b5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -3,6 +3,7 @@ /stats.html /bower_components /coverage +/generated-docs /node_modules /npm-debug.log /public/**/* diff --git a/frontend/doc/README.md b/frontend/doc/README.md index 73e18d56b2c0..c43ed96e6a31 100644 --- a/frontend/doc/README.md +++ b/frontend/doc/README.md @@ -50,6 +50,17 @@ The style guide is available as part of the Rails development server at: Date: Fri, 4 Sep 2026 21:59:43 +0100 Subject: [PATCH 03/13] [OP-18657] Fix TSDoc syntax in Stimulus doc comments TypeDoc parses doc comments as TSDoc, not JSDoc, so a missing `@param` hyphen, a `{Type}` after `@throws` or an unfenced code sample is dropped or mangled in the generated output without warning. Relocating the `@see` in CheckAllController also keeps the two paragraphs that followed it out of the link. --- .../controllers/check-all.controller.ts | 4 +-- .../controllers/checkable.controller.ts | 16 ++++++++---- .../controllers/op-application.controller.ts | 2 +- .../helpers/live-collaboration-helpers.ts | 12 ++++----- frontend/src/stimulus/helpers/url-helpers.ts | 6 ++--- .../stimulus/mixins/use-angular-services.ts | 26 ++++++++++--------- .../openproject-stimulus-application.ts | 10 ++++--- 7 files changed, 43 insertions(+), 33 deletions(-) diff --git a/frontend/src/stimulus/controllers/check-all.controller.ts b/frontend/src/stimulus/controllers/check-all.controller.ts index f712f7a31cc2..f492709d3e50 100644 --- a/frontend/src/stimulus/controllers/check-all.controller.ts +++ b/frontend/src/stimulus/controllers/check-all.controller.ts @@ -43,13 +43,13 @@ type CheckableElement = ExtractElement; * all" links and buttons are outside scope of a `CheckableController`, i.e. in * another part of the DOM that is not a descendant. * - * @see https://stimulus.hotwired.dev/reference/outlets - * * This controller also handles setting `aria-controls` on its HTML element. * * Rather than using targets, it is up to the implementer to "wire up" events * using descriptors. This is designed for maximum flexibility. * + * @see [Stimulus outlets](https://stimulus.hotwired.dev/reference/outlets) + * * @example * ```html *
diff --git a/frontend/src/stimulus/controllers/checkable.controller.ts b/frontend/src/stimulus/controllers/checkable.controller.ts index e40476aef577..7be1f965f3dc 100644 --- a/frontend/src/stimulus/controllers/checkable.controller.ts +++ b/frontend/src/stimulus/controllers/checkable.controller.ts @@ -27,6 +27,12 @@ //++ import { Controller, ActionEvent } from '@hotwired/stimulus'; +// Imported for the {@link} reference below; TypeDoc resolves declaration +// references through scope, and its `module!name` form is rejected by the +// TSDoc syntax rule while the TSDoc `module#name` form it accepts does not +// resolve here. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type CheckAllController from './check-all.controller'; import invariant from 'tiny-invariant'; /** @@ -39,7 +45,7 @@ import invariant from 'tiny-invariant'; * * Rather than defining event handlers within the controller, this controller * uses Stimulus actions. The implementer is responsible for adding appropriate - * {@link https://stimulus.hotwired.dev/reference/actions#descriptors action descriptors} + * [action descriptors](https://stimulus.hotwired.dev/reference/actions#descriptors) * to HTML elements that should trigger the controller's methods. * * Can be used standalone or in combination with {@link CheckAllController} @@ -118,11 +124,11 @@ export default class CheckableController extends Controller { * by `key`) against a value (specified by `value`). Useful for table-like * UIs where you want to toggle checkboxes by row or column. * - * @param event - The ActionEvent containing params - * @param event.params.key - The data attribute name to filter by (camelCase) - * @param event.params.value - The value to match (will be converted to string) + * @param event - The ActionEvent whose `params.key` names the data attribute + * to filter by (camelCase) and whose `params.value` is matched against it + * (converted to a string) * - * @throws {Error} If key or value params are missing + * @throws Error If key or value params are missing * * @example Toggle all checkboxes where data-column-id="3" * ```html diff --git a/frontend/src/stimulus/controllers/op-application.controller.ts b/frontend/src/stimulus/controllers/op-application.controller.ts index b559bde9b394..c1e7b5a58f9c 100644 --- a/frontend/src/stimulus/controllers/op-application.controller.ts +++ b/frontend/src/stimulus/controllers/op-application.controller.ts @@ -103,7 +103,7 @@ export class OpApplicationController extends ApplicationController { * We convert these to slashes for the dynamic import. * * https://stimulus.hotwired.dev/handbook/installing#controller-filenames-map-to-identifiers - * @param controller + * @param controller - The controller identifier * @private */ private derivePath(controller:string):string { diff --git a/frontend/src/stimulus/helpers/live-collaboration-helpers.ts b/frontend/src/stimulus/helpers/live-collaboration-helpers.ts index e69149c9d2e4..1f44fe9cfe10 100644 --- a/frontend/src/stimulus/helpers/live-collaboration-helpers.ts +++ b/frontend/src/stimulus/helpers/live-collaboration-helpers.ts @@ -63,9 +63,9 @@ class LiveCollaborationManagerClass { * existing session rather than calling this with a fresh provider, since * this method unconditionally tears down the previous provider/doc. * - * @param provider The provider to use - * @param doc The Y.Doc instance to use - * @param documentName Logical identifier of the document being edited + * @param provider - The provider to use + * @param doc - The Y.Doc instance to use + * @param documentName - Logical identifier of the document being edited * @returns void */ initializeYjsProvider(provider:HocuspocusProvider, doc:Doc, documentName:string) { @@ -86,7 +86,7 @@ class LiveCollaborationManagerClass { * controller's connect(). Without an ownership check, the old controller would destroy the * new provider, causing a spurious "connection error" banner. * - * @param provider The provider instance requesting destruction; treated as the + * @param provider - The provider instance requesting destruction; treated as the * candidate owner of the current collaboration session. * @returns `true` if the given provider was the current owner and the internal * provider/doc instances were destroyed; `false` otherwise. @@ -143,7 +143,7 @@ class LiveCollaborationManagerClass { * with the current {@link HocuspocusProvider} instance. Otherwise, the * listener is stored and invoked later once {@link initializeYjsProvider} is called. * - * @param listener Callback that receives the ready { @link HocuspocusProvider } + * @param listener - Callback that receives the ready {@link HocuspocusProvider} * */ onReady(listener:Listener) { @@ -155,7 +155,7 @@ class LiveCollaborationManagerClass { /** * Unregisters a previously registered ready listener. - * @param listener The listener function to remove + * @param listener - The listener function to remove */ offReady(listener:Listener):void { const index = this.listeners.indexOf(listener); diff --git a/frontend/src/stimulus/helpers/url-helpers.ts b/frontend/src/stimulus/helpers/url-helpers.ts index 0f63d2a8be03..4cfff748e2bf 100644 --- a/frontend/src/stimulus/helpers/url-helpers.ts +++ b/frontend/src/stimulus/helpers/url-helpers.ts @@ -29,9 +29,9 @@ /** * Extend a given URL (string or URL object) with the provided search parameters. * - * @param base The base URL to extend - * @param params A record of key-value pairs to add as search parameters - * @param addCurrentSearch Whether to include the current window's search parameters (default: true) + * @param base - The base URL to extend + * @param params - A record of key-value pairs to add as search parameters + * @param addCurrentSearch - Whether to include the current window's search parameters (default: true) */ export function extendSearchParams( base:string, diff --git a/frontend/src/stimulus/mixins/use-angular-services.ts b/frontend/src/stimulus/mixins/use-angular-services.ts index 4f3a88f6246e..e340574babfc 100644 --- a/frontend/src/stimulus/mixins/use-angular-services.ts +++ b/frontend/src/stimulus/mixins/use-angular-services.ts @@ -44,20 +44,22 @@ interface ServiceConsumer { * * Usage: * - * export default class ListRefreshController extends Controller { - * static services:ServiceKey[] = ['halEvents']; - * declare halEvents:HalEventsService; + * ```ts + * export default class ListRefreshController extends Controller { + * static services:ServiceKey[] = ['halEvents']; + * declare halEvents:HalEventsService; * - * initialize() { - * useAngularServices(this); - * } + * initialize() { + * useAngularServices(this); + * } * - * // Fires after every connect(), once the context has resolved and the - * // element is still connected. - * servicesConnected() { - * this.subscription = this.halEvents.aggregated$('WorkPackage')... - * } - * } + * // Fires after every connect(), once the context has resolved and the + * // element is still connected. + * servicesConnected() { + * this.subscription = this.halEvents.aggregated$('WorkPackage')... + * } + * } + * ``` * * For use outside `servicesConnected()` (e.g. event handlers), the mixin also * defines two promise properties on the controller (add matching `declare` diff --git a/frontend/src/stimulus/openproject-stimulus-application.ts b/frontend/src/stimulus/openproject-stimulus-application.ts index 61e469eb7c4d..aef79e67be05 100644 --- a/frontend/src/stimulus/openproject-stimulus-application.ts +++ b/frontend/src/stimulus/openproject-stimulus-application.ts @@ -44,8 +44,8 @@ export class OpenProjectStimulusApplication extends Application { * * This is useful for plugins that execute code before we call setup.ts * - * @param name the name/identifier of the controller - * @param controller the controller class + * @param name - The name/identifier of the controller + * @param controller - The controller class */ static preregister(name:string, controller:ControllerConstructor) { this.controllers.set(name, controller); @@ -57,15 +57,17 @@ export class OpenProjectStimulusApplication extends Application { * * This is useful for plugins that want to define new dynamic controllers. * How to use this: In your plugin's main.ts, call this + * * @example + * ```ts * OpenProjectStimulusApplication.preregisterDynamic( * 'test', * () => import('./test.controller') * ); * ``` * - * @param name the name/identifier of the controller - * @param loader A callback to provide the controller asynchronously. + * @param name - The name/identifier of the controller + * @param loader - A callback to provide the controller asynchronously. */ static preregisterDynamic(name:string, loader:DynamicControllerLoader) { this.dynamicImports.set(name, loader); From 7c697379c8814b40cad79996e3f51aeef8e41d0c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 21:59:44 +0100 Subject: [PATCH 04/13] [OP-18657] Lint Stimulus doc comments as TSDoc Scoped to the `entryPoints` of `typedoc.json` rather than all of `src`, since the legacy Angular tree carries several hundred JSDoc comments that TypeDoc never renders. --- frontend/eslint.config.mjs | 9 +++++++++ frontend/tsdoc.json | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 frontend/tsdoc.json diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 0f331c4f8db0..d8570de845ec 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -35,6 +35,7 @@ import vitest from '@vitest/eslint-plugin'; import angular from 'angular-eslint'; import stylistic from '@stylistic/eslint-plugin'; import headers from 'eslint-plugin-headers'; +import tsdoc from 'eslint-plugin-tsdoc'; import { defineConfig, globalIgnores } from 'eslint/config'; @@ -226,6 +227,14 @@ export default defineConfig([ 'max-classes-per-file': 'off', }, }, + { + // Scoped to the `entryPoints` of `typedoc.json`; widen alongside it. + files: ['src/stimulus/**/*.ts'], + plugins: { tsdoc }, + rules: { + 'tsdoc/syntax': 'error', + }, + }, { plugins: { '@stylistic': stylistic }, rules: { diff --git a/frontend/tsdoc.json b/frontend/tsdoc.json new file mode 100644 index 000000000000..b89839ca227a --- /dev/null +++ b/frontend/tsdoc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["typedoc/tsdoc.json"] +} From 7b1ca7009c7461c1574bc25bbff24d5752aa88c6 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 21:59:44 +0100 Subject: [PATCH 05/13] [OP-18657] Publish frontend API documentation Builds the reference for both the development tip and the latest release branch, so the published site can describe either. TypeDoc is installed ad-hoc rather than from the source checkout, letting release branches that predate this tooling still build. The generated output is validated before publishing, so a scope or naming regression fails the build rather than reaching the site. --- .github/workflows/frontend-api-docs.yml | 330 ++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 .github/workflows/frontend-api-docs.yml diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml new file mode 100644 index 000000000000..00a07ce703c8 --- /dev/null +++ b/.github/workflows/frontend-api-docs.yml @@ -0,0 +1,330 @@ +name: Frontend API documentation + +on: + push: + branches: + - dev + paths: + - ".github/workflows/frontend-api-docs.yml" + - "frontend/doc/**" + - "frontend/package.json" + - "frontend/package-lock.json" + - "frontend/typedoc.json" + - "frontend/src/stimulus/**" + pull_request: + types: [opened, reopened, synchronize] + paths: + - ".github/workflows/frontend-api-docs.yml" + - "frontend/doc/**" + - "frontend/package.json" + - "frontend/package-lock.json" + - "frontend/typedoc.json" + - "frontend/src/stimulus/**" + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + inputs: + edge_ref: + description: "Git ref to publish as edge. Defaults to dev." + required: false + type: string + stage_ref: + description: "Git ref to publish as stage. Defaults to the latest protected release branch." + required: false + type: string + publish: + description: "Publish the generated site to GitHub Pages." + default: false + required: true + type: boolean + +permissions: + contents: read + +# A superseded run must not finish building and then publish over the newer +# one; the deploy job's own `pages` group serialises but never cancels. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare: + name: Resolve source refs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + edge_ref: ${{ steps.refs.outputs.edge_ref }} + edge_repository: ${{ steps.refs.outputs.edge_repository }} + stage_ref: ${{ steps.refs.outputs.stage_ref }} + stage_repository: ${{ steps.refs.outputs.stage_repository }} + steps: + - name: Resolve edge and stage refs + id: refs + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + INPUT_EDGE_REF: ${{ inputs.edge_ref }} + INPUT_STAGE_REF: ${{ inputs.stage_ref }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "pull_request" ]; then + edge_repository="$PR_HEAD_REPOSITORY" + edge_ref="$PR_HEAD_SHA" + else + edge_repository="$REPOSITORY" + edge_ref="${INPUT_EDGE_REF:-dev}" + fi + + stage_repository="opf/openproject" + if [ -n "$INPUT_STAGE_REF" ]; then + stage_ref="$INPUT_STAGE_REF" + else + protected_branches=$(gh api --paginate \ + "repos/$stage_repository/branches?protected=true&per_page=100" \ + --jq '.[].name') + stage_ref=$(printf '%s\n' "$protected_branches" | \ + grep '^release/' | sort --version-sort | tail -1 || true) + fi + + if [ -z "$stage_ref" ]; then + echo "Error: no protected release branch found" >&2 + exit 1 + fi + + { + echo "edge_repository=$edge_repository" + echo "edge_ref=$edge_ref" + echo "stage_repository=$stage_repository" + echo "stage_ref=$stage_ref" + } >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.channel }} documentation + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - channel: edge + repository: ${{ needs.prepare.outputs.edge_repository }} + ref: ${{ needs.prepare.outputs.edge_ref }} + - channel: stage + repository: ${{ needs.prepare.outputs.stage_repository }} + ref: ${{ needs.prepare.outputs.stage_ref }} + steps: + - name: Check out documentation tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: tooling + persist-credentials: false + + - name: Check out ${{ matrix.channel }} source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ matrix.repository }} + ref: ${{ matrix.ref }} + path: source + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: source/package.json + package-manager-cache: false + + - name: Install source dependencies + working-directory: source/frontend + run: npm ci + + - name: Register plugin frontends + working-directory: source/frontend + run: npm run ci:plugins:register_frontend + + - name: Install documentation tooling + working-directory: source/frontend + run: | # zizmor: ignore[adhoc-packages] TypeDoc must resolve the source checkout's TypeScript. + cp "$GITHUB_WORKSPACE/tooling/frontend/typedoc.json" typedoc.ci.json + npm install --no-save --ignore-scripts typedoc@0.28.20 typedoc-github-theme@0.4.0 + + - name: Generate TypeDoc + env: + CHANNEL: ${{ matrix.channel }} + working-directory: source/frontend + run: | + output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" + ./node_modules/.bin/typedoc --options typedoc.ci.json --out "$output" + + - name: Validate TypeDoc output + env: + CHANNEL: ${{ matrix.channel }} + run: | + set -euo pipefail + output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" + test -f "$output/classes/controllers_async-dialog.controller.default.html" + test -f "$output/classes/controllers_dynamic_sortable-lists_list.controller.default.html" + test -f "$output/functions/helpers_request-helpers.post.html" + test -f "$output/functions/mixins_use-angular-services.useAngularServices.html" + + if find "$output" -type f -print | grep -F '.spec.'; then + echo "Error: TypeDoc output contains spec modules" >&2 + exit 1 + fi + + if find "$output/modules" -type f \ + \( -name 'app_*' -o -name 'react_*' -o -name 'turbo_*' \) \ + -print | grep -q .; then + echo "Error: TypeDoc output contains APIs outside the Stimulus scope" >&2 + exit 1 + fi + + - name: Record source metadata + env: + CHANNEL: ${{ matrix.channel }} + SOURCE_REF: ${{ matrix.ref }} + SOURCE_REPOSITORY: ${{ matrix.repository }} + working-directory: source + run: | + sha=$(git rev-parse HEAD) + short_sha=$(git rev-parse --short HEAD) + channel_directory="$GITHUB_WORKSPACE/site/$CHANNEL" + + jq -n \ + --arg channel "$CHANNEL" \ + --arg repository "$SOURCE_REPOSITORY" \ + --arg ref "$SOURCE_REF" \ + --arg sha "$sha" \ + --arg short_sha "$short_sha" \ + '{channel: $channel, repository: $repository, ref: $ref, sha: $sha, short_sha: $short_sha}' \ + > "$channel_directory/metadata.json" + + { + echo "### $CHANNEL documentation" + echo + echo "Built from \`$SOURCE_REPOSITORY@$SOURCE_REF\` ([$short_sha](https://github.com/$SOURCE_REPOSITORY/commit/$sha))." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload ${{ matrix.channel }} documentation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-api-docs-${{ matrix.channel }} + path: site/${{ matrix.channel }} + if-no-files-found: error + retention-days: 7 + + assemble: + name: Assemble documentation site + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download edge documentation + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: frontend-api-docs-edge + path: site/edge + + - name: Download stage documentation + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: frontend-api-docs-stage + path: site/stage + + - name: Create landing page + run: | + node <<'NODE' + const fs = require('node:fs'); + + const escapeHtml = (value) => value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]); + + const channel = (name) => { + const metadata = JSON.parse(fs.readFileSync(`site/${name}/metadata.json`, 'utf8')); + return { + name, + ref: escapeHtml(metadata.ref), + repository: escapeHtml(metadata.repository), + sha: escapeHtml(metadata.sha), + shortSha: escapeHtml(metadata.short_sha), + }; + }; + + const items = ['edge', 'stage'].map(channel).map((metadata) => ` +
  • +

    ${metadata.name}

    +

    ${metadata.repository}@${metadata.ref}

    +

    Commit ${metadata.shortSha}

    +
  • `).join(''); + + fs.writeFileSync('site/index.html', ` + + + + + OpenProject API documentation + + + +
    +

    OpenProject API documentation

    +

    Generated reference documentation for reusable frontend APIs.

    +
      ${items} +
    +
    + + + `); + NODE + + - name: Upload combined documentation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-api-docs-site + path: site + if-no-files-found: error + retention-days: 7 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + name: Deploy documentation to GitHub Pages + needs: assemble + if: >- + github.repository == 'opf/openproject' && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_dispatch' || inputs.publish) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 From 93a49c32ce8301015c39a6d80128c8f70818441a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 22:37:50 +0100 Subject: [PATCH 06/13] [OP-18657] Add a test harness for doc tooling --- frontend/eslint.config.mjs | 7 ++- frontend/package.json | 1 + .../typedoc/__fixtures__/simple/sample.ts | 37 +++++++++++ frontend/tooling/typedoc/run-typedoc.mjs | 61 +++++++++++++++++++ frontend/tooling/typedoc/run-typedoc.spec.mjs | 39 ++++++++++++ frontend/tooling/typedoc/tsconfig.json | 9 +++ frontend/vitest.tooling.config.ts | 39 ++++++++++++ 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 frontend/tooling/typedoc/__fixtures__/simple/sample.ts create mode 100644 frontend/tooling/typedoc/run-typedoc.mjs create mode 100644 frontend/tooling/typedoc/run-typedoc.spec.mjs create mode 100644 frontend/tooling/typedoc/tsconfig.json create mode 100644 frontend/vitest.tooling.config.ts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d8570de845ec..ed93687c5ec5 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -88,7 +88,12 @@ export default defineConfig([ processor: angular.processInlineTemplates, languageOptions: { parserOptions: { - projectService: true, + // Node-side tooling configs (e.g. `vitest.tooling.config.ts`) live at + // the frontend root but aren't referenced by any `tsconfig.json`, so + // they need an in-memory default project to be lintable. + projectService: { + allowDefaultProject: ['vitest.tooling.config.ts'], + }, tsconfigRootDir: import.meta.dirname, }, globals: { ...globals.browser, ...globals.node }, diff --git a/frontend/package.json b/frontend/package.json index 9a857fac9634..cc3766566635 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -198,6 +198,7 @@ "serve": "PORT=${FE_PORT:-4200} node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng serve --host ${FE_HOST:-localhost} --port ${FE_PORT:-4200} --serve-path ${RAILS_RELATIVE_URL_ROOT}/assets/frontend", "test": "ng test --watch=false", "test:watch": "ng test --watch=true", + "test:tooling": "vitest run --config vitest.tooling.config.ts", "lint": "ng lint", "lint:fix": "ng lint --fix", "generate-typings": "tsc -d -p tsconfig.app.json", diff --git a/frontend/tooling/typedoc/__fixtures__/simple/sample.ts b/frontend/tooling/typedoc/__fixtures__/simple/sample.ts new file mode 100644 index 000000000000..9a7cf933acb6 --- /dev/null +++ b/frontend/tooling/typedoc/__fixtures__/simple/sample.ts @@ -0,0 +1,37 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/** + * Adds two numbers. + * + * @param a - The first addend + * @param b - The second addend + */ +export function add(a:number, b:number):number { + return a + b; +} diff --git a/frontend/tooling/typedoc/run-typedoc.mjs b/frontend/tooling/typedoc/run-typedoc.mjs new file mode 100644 index 000000000000..ac514e04c45d --- /dev/null +++ b/frontend/tooling/typedoc/run-typedoc.mjs @@ -0,0 +1,61 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { fileURLToPath } from 'node:url'; +import { Application, PackageJsonReader, TSConfigReader } from 'typedoc'; + +const fixturesRoot = fileURLToPath(new URL('./__fixtures__/', import.meta.url)); +const toolingTsconfig = fileURLToPath(new URL('./tsconfig.json', import.meta.url)); + +// Skip TypeDocReader so the repo's own `typedoc.json` (scoped to +// `src/stimulus/**`) never leaks into fixture conversions. +const readers = [new PackageJsonReader(), new TSConfigReader()]; + +/** + * Converts a fixture directory with TypeDoc and returns the reflection model. + * + * @param options - Fixture name, plugins to load, and TypeDoc option overrides + * @returns The converted project reflection + */ +export async function buildFixtureProject({ fixture, plugins = [], options = {} }) { + const app = await Application.bootstrapWithPlugins({ + entryPoints: [`${fixturesRoot}${fixture}`], + entryPointStrategy: 'expand', + plugin: plugins, + logLevel: 'Error', + tsconfig: toolingTsconfig, + ...options, + }, readers); + + const project = await app.convert(); + if (!project) { + throw new Error(`TypeDoc failed to convert fixture "${fixture}"`); + } + + return project; +} diff --git a/frontend/tooling/typedoc/run-typedoc.spec.mjs b/frontend/tooling/typedoc/run-typedoc.spec.mjs new file mode 100644 index 000000000000..83167105a87f --- /dev/null +++ b/frontend/tooling/typedoc/run-typedoc.spec.mjs @@ -0,0 +1,39 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { describe, expect, it } from 'vitest'; +import { buildFixtureProject } from './run-typedoc.mjs'; + +describe('buildFixtureProject', () => { + it('converts a fixture into a reflection model', async () => { + const project = await buildFixtureProject({ fixture: 'simple' }); + const names = Object.values(project.reflections).map((r) => r.name); + + expect(names).toContain('add'); + }); +}); diff --git a/frontend/tooling/typedoc/tsconfig.json b/frontend/tooling/typedoc/tsconfig.json new file mode 100644 index 000000000000..610b1a61b22c --- /dev/null +++ b/frontend/tooling/typedoc/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/tooling", + "types": ["node"] + }, + "files": [], + "include": ["**/*.ts"] +} diff --git a/frontend/vitest.tooling.config.ts b/frontend/vitest.tooling.config.ts new file mode 100644 index 000000000000..9eb1a9583e17 --- /dev/null +++ b/frontend/vitest.tooling.config.ts @@ -0,0 +1,39 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { defineConfig } from 'vitest/config'; + +// The app's specs run in the browser via the Angular builder. Documentation +// tooling is Node-side and cannot run there, so it gets its own project. +export default defineConfig({ + test: { + environment: 'node', + include: ['tooling/**/*.spec.mjs'], + testTimeout: 60_000, + }, +}); From 1e17e6e0290fab7f591d476acdd3b46a4fc25980 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 22:43:19 +0100 Subject: [PATCH 07/13] [OP-18657] Drop vendored paths from the reference --- .github/workflows/frontend-api-docs.yml | 3 + .../__fixtures__/vendored/uses-vendored.ts | 36 ++++++++++ .../tooling/typedoc/openproject-plugin.mjs | 68 +++++++++++++++++++ .../typedoc/openproject-plugin.spec.mjs | 57 ++++++++++++++++ frontend/typedoc.json | 2 +- 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 frontend/tooling/typedoc/__fixtures__/vendored/uses-vendored.ts create mode 100644 frontend/tooling/typedoc/openproject-plugin.mjs create mode 100644 frontend/tooling/typedoc/openproject-plugin.spec.mjs diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml index 00a07ce703c8..9d10f9c77a7b 100644 --- a/.github/workflows/frontend-api-docs.yml +++ b/.github/workflows/frontend-api-docs.yml @@ -150,6 +150,9 @@ jobs: working-directory: source/frontend run: | # zizmor: ignore[adhoc-packages] TypeDoc must resolve the source checkout's TypeScript. cp "$GITHUB_WORKSPACE/tooling/frontend/typedoc.json" typedoc.ci.json + cp "$GITHUB_WORKSPACE/tooling/frontend/tsdoc.json" tsdoc.json + mkdir -p tooling + cp -R "$GITHUB_WORKSPACE/tooling/frontend/tooling/typedoc" tooling/ npm install --no-save --ignore-scripts typedoc@0.28.20 typedoc-github-theme@0.4.0 - name: Generate TypeDoc diff --git a/frontend/tooling/typedoc/__fixtures__/vendored/uses-vendored.ts b/frontend/tooling/typedoc/__fixtures__/vendored/uses-vendored.ts new file mode 100644 index 000000000000..d7f1ca7837af --- /dev/null +++ b/frontend/tooling/typedoc/__fixtures__/vendored/uses-vendored.ts @@ -0,0 +1,36 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; + +/** A controller inheriting members from the vendored Stimulus base class. */ +export default class SampleController extends Controller { + connect():void { + this.element.dataset.connected = 'true'; + } +} diff --git a/frontend/tooling/typedoc/openproject-plugin.mjs b/frontend/tooling/typedoc/openproject-plugin.mjs new file mode 100644 index 000000000000..f91b313a7d78 --- /dev/null +++ b/frontend/tooling/typedoc/openproject-plugin.mjs @@ -0,0 +1,68 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Converter } from 'typedoc'; + +/** + * Members inherited from vendored base classes carry the source path of their + * `.d.ts` file. With `sourceLinkTemplate` configured those render as links to + * paths that do not exist in the repository, so they are dropped entirely. + * + * @param project - The converted project reflection + * @returns The number of source entries removed + */ +function stripVendoredSources(project) { + let stripped = 0; + + for (const reflection of Object.values(project.reflections)) { + const { sources } = reflection; + if (!sources) { + continue; + } + + const kept = sources.filter((source) => !source.fileName.includes('node_modules')); + if (kept.length !== sources.length) { + stripped += sources.length - kept.length; + reflection.sources = kept.length > 0 ? kept : undefined; + } + } + + return stripped; +} + +/** + * TypeDoc plugin entry point. + * + * @param app - The TypeDoc application to extend + */ +export function load(app) { + app.converter.on(Converter.EVENT_END, (context) => { + const stripped = stripVendoredSources(context.project); + app.logger.verbose(`Stripped ${stripped} vendored source entries`); + }); +} diff --git a/frontend/tooling/typedoc/openproject-plugin.spec.mjs b/frontend/tooling/typedoc/openproject-plugin.spec.mjs new file mode 100644 index 000000000000..49e1a913bc72 --- /dev/null +++ b/frontend/tooling/typedoc/openproject-plugin.spec.mjs @@ -0,0 +1,57 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { buildFixtureProject } from './run-typedoc.mjs'; + +const plugin = fileURLToPath(new URL('./openproject-plugin.mjs', import.meta.url)); + +function allSources(project) { + const sources = []; + for (const reflection of Object.values(project.reflections)) { + sources.push(...(reflection.sources ?? [])); + } + return sources; +} + +describe('vendored source stripping', () => { + it('leaves no source entries pointing into node_modules', async () => { + const project = await buildFixtureProject({ fixture: 'vendored', plugins: [plugin] }); + const vendored = allSources(project).filter((s) => s.fileName.includes('node_modules')); + + expect(vendored).toHaveLength(0); + }); + + it('keeps source entries for first-party code', async () => { + const project = await buildFixtureProject({ fixture: 'vendored', plugins: [plugin] }); + const firstParty = allSources(project).filter((s) => s.fileName.includes('uses-vendored')); + + expect(firstParty.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/typedoc.json b/frontend/typedoc.json index 08df28dd0305..b7983483af9a 100644 --- a/frontend/typedoc.json +++ b/frontend/typedoc.json @@ -8,7 +8,7 @@ "src/stimulus/openproject-stimulus-application.ts", "src/stimulus/test-helpers.ts" ], - "plugin": ["typedoc-github-theme"], + "plugin": ["typedoc-github-theme", "./tooling/typedoc/openproject-plugin.mjs"], "out": "./generated-docs", "projectDocuments": ["doc/**/*.md"], "tsconfig": "tsconfig.app.json" From 1417bf9cd10f9e3ff3c844a33953d5ffc6ef2b45 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 22:50:28 +0100 Subject: [PATCH 08/13] [OP-18657] Link documented symbols to their source --- .github/workflows/frontend-api-docs.yml | 6 ++++++ frontend/package.json | 2 +- frontend/typedoc.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml index 9d10f9c77a7b..ebc928a60385 100644 --- a/.github/workflows/frontend-api-docs.yml +++ b/.github/workflows/frontend-api-docs.yml @@ -158,9 +158,15 @@ jobs: - name: Generate TypeDoc env: CHANNEL: ${{ matrix.channel }} + SOURCE_REPOSITORY: ${{ matrix.repository }} working-directory: source/frontend run: | output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" + sha=$(git -C "$GITHUB_WORKSPACE/source" rev-parse HEAD) + template="https://github.com/$SOURCE_REPOSITORY/blob/$sha/frontend/src/stimulus/{path}#L{line}" + jq --arg template "$template" '.sourceLinkTemplate = $template | del(.gitRevision)' \ + typedoc.ci.json > typedoc.ci.next.json + mv typedoc.ci.next.json typedoc.ci.json ./node_modules/.bin/typedoc --options typedoc.ci.json --out "$output" - name: Validate TypeDoc output diff --git a/frontend/package.json b/frontend/package.json index cc3766566635..599579f1359b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -202,7 +202,7 @@ "lint": "ng lint", "lint:fix": "ng lint --fix", "generate-typings": "tsc -d -p tsconfig.app.json", - "generate-docs": "npm run ci:plugins:register_frontend && typedoc", + "generate-docs": "npm run ci:plugins:register_frontend && typedoc --gitRevision $(git rev-parse HEAD)", "postinstall": "patch-package" }, "allowScripts": { diff --git a/frontend/typedoc.json b/frontend/typedoc.json index b7983483af9a..d5a68c33dd60 100644 --- a/frontend/typedoc.json +++ b/frontend/typedoc.json @@ -10,6 +10,9 @@ ], "plugin": ["typedoc-github-theme", "./tooling/typedoc/openproject-plugin.mjs"], "out": "./generated-docs", + "disableGit": true, + "gitRevision": "dev", + "sourceLinkTemplate": "https://github.com/opf/openproject/blob/{gitRevision}/frontend/src/stimulus/{path}#L{line}", "projectDocuments": ["doc/**/*.md"], "tsconfig": "tsconfig.app.json" } From 63b53316992a48f475e3e4f21c6d4e0e07afb39b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 22:54:33 +0100 Subject: [PATCH 09/13] [OP-18657] Exclude external symbols from the docs --- frontend/typedoc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/typedoc.json b/frontend/typedoc.json index d5a68c33dd60..3cdf0d9d0882 100644 --- a/frontend/typedoc.json +++ b/frontend/typedoc.json @@ -9,6 +9,7 @@ "src/stimulus/test-helpers.ts" ], "plugin": ["typedoc-github-theme", "./tooling/typedoc/openproject-plugin.mjs"], + "excludeExternals": true, "out": "./generated-docs", "disableGit": true, "gitRevision": "dev", From 7d3a438c2ca398452f5b2ccf09fa5b72db1331f2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 22:56:03 +0100 Subject: [PATCH 10/13] [OP-18657] Name controller pages by class --- .github/workflows/frontend-api-docs.yml | 6 +++--- frontend/package-lock.json | 27 +++++++++++++++++++++++++ frontend/package.json | 1 + frontend/typedoc.json | 6 +++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml index ebc928a60385..e38adf47d8a5 100644 --- a/.github/workflows/frontend-api-docs.yml +++ b/.github/workflows/frontend-api-docs.yml @@ -153,7 +153,7 @@ jobs: cp "$GITHUB_WORKSPACE/tooling/frontend/tsdoc.json" tsdoc.json mkdir -p tooling cp -R "$GITHUB_WORKSPACE/tooling/frontend/tooling/typedoc" tooling/ - npm install --no-save --ignore-scripts typedoc@0.28.20 typedoc-github-theme@0.4.0 + npm install --no-save --ignore-scripts typedoc@0.28.20 typedoc-github-theme@0.4.0 typedoc-plugin-rename-defaults@0.7.3 - name: Generate TypeDoc env: @@ -175,8 +175,8 @@ jobs: run: | set -euo pipefail output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" - test -f "$output/classes/controllers_async-dialog.controller.default.html" - test -f "$output/classes/controllers_dynamic_sortable-lists_list.controller.default.html" + test -f "$output/classes/controllers_async-dialog.controller.AsyncDialogController.html" + test -f "$output/classes/controllers_dynamic_sortable-lists_list.controller.ListController.html" test -f "$output/functions/helpers_request-helpers.post.html" test -f "$output/functions/mixins_use-angular-services.useAngularServices.html" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ec35fc19264..e4670f5d6da2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -182,6 +182,7 @@ "ts-node": "~10.9.2", "typedoc": "^0.28.20", "typedoc-github-theme": "^0.4.0", + "typedoc-plugin-rename-defaults": "^0.7.3", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vitest": "^4.1.10", @@ -8302,6 +8303,19 @@ "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -16477,6 +16491,19 @@ "typedoc": "~0.28.0" } }, + "node_modules/typedoc-plugin-rename-defaults": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/typedoc-plugin-rename-defaults/-/typedoc-plugin-rename-defaults-0.7.3.tgz", + "integrity": "sha512-fDtrWZ9NcDfdGdlL865GW7uIGQXlthPscURPOhDkKUe4DBQSRRFUf33fhWw41FLlsz8ZTeSxzvvuNmh54MynFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0" + }, + "peerDependencies": { + "typedoc": ">=0.22.x <0.29.x" + } + }, "node_modules/typedoc/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 599579f1359b..bda6ba839a8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,6 +53,7 @@ "ts-node": "~10.9.2", "typedoc": "^0.28.20", "typedoc-github-theme": "^0.4.0", + "typedoc-plugin-rename-defaults": "^0.7.3", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vitest": "^4.1.10", diff --git a/frontend/typedoc.json b/frontend/typedoc.json index 3cdf0d9d0882..fd1f8d6fac4e 100644 --- a/frontend/typedoc.json +++ b/frontend/typedoc.json @@ -8,7 +8,11 @@ "src/stimulus/openproject-stimulus-application.ts", "src/stimulus/test-helpers.ts" ], - "plugin": ["typedoc-github-theme", "./tooling/typedoc/openproject-plugin.mjs"], + "plugin": [ + "typedoc-github-theme", + "typedoc-plugin-rename-defaults", + "./tooling/typedoc/openproject-plugin.mjs" + ], "excludeExternals": true, "out": "./generated-docs", "disableGit": true, From 4d10105e9d6965cdcdb0fe6fa1ec0522a2d93ce3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 23:13:16 +0100 Subject: [PATCH 11/13] [OP-18657] Run tooling tests in CI test:tooling covered doc-generation tooling but no workflow called it, so a regression there would go undetected. Run it once per matrix job, on the chromium leg only. --- .github/workflows/test-frontend-unit.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test-frontend-unit.yml b/.github/workflows/test-frontend-unit.yml index d2861cfedc2f..c08781989eb4 100644 --- a/.github/workflows/test-frontend-unit.yml +++ b/.github/workflows/test-frontend-unit.yml @@ -9,6 +9,7 @@ on: paths: - '**/frontend/**/*.ts' - '**/frontend/**/*.js' + - '**/frontend/**/*.mjs' - '**/frontend/**/*.json' - '.github/workflows/test-frontend-unit.yml' @@ -17,6 +18,7 @@ on: paths: - '**/frontend/**/*.ts' - '**/frontend/**/*.js' + - '**/frontend/**/*.mjs' - '**/frontend/**/*.json' - '.github/workflows/test-frontend-unit.yml' @@ -81,3 +83,7 @@ jobs: - name: Test run: npm test -- --browsers ${{ matrix.browser }} --reporters dot --reporters github-actions + + - name: Test tooling + if: matrix.browser == 'chromium' + run: npm run test:tooling From 9795387907df87b948cb238ba343d517c89eff06 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 23:13:23 +0100 Subject: [PATCH 12/13] [OP-18657] Harden the frontend API docs workflow Trigger paths omitted the tooling directory and tsdoc.json, so changes there would not rerun the build. Page-name assertions ran for the stage channel too, which tracks a release branch this workflow does not control. The out-of-scope module check passed vacuously when the modules directory was missing. And edge_ref/stage_ref could carry workflow_dispatch input into GITHUB_OUTPUT unescaped. Add the missing trigger paths, restrict exact page-name checks to edge, require the modules directory to exist, and switch edge_ref/stage_ref to the GITHUB_OUTPUT heredoc form. --- .github/workflows/frontend-api-docs.yml | 46 +++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml index e38adf47d8a5..4c892822230f 100644 --- a/.github/workflows/frontend-api-docs.yml +++ b/.github/workflows/frontend-api-docs.yml @@ -9,6 +9,8 @@ on: - "frontend/doc/**" - "frontend/package.json" - "frontend/package-lock.json" + - "frontend/tooling/**" + - "frontend/tsdoc.json" - "frontend/typedoc.json" - "frontend/src/stimulus/**" pull_request: @@ -18,6 +20,8 @@ on: - "frontend/doc/**" - "frontend/package.json" - "frontend/package-lock.json" + - "frontend/tooling/**" + - "frontend/tsdoc.json" - "frontend/typedoc.json" - "frontend/src/stimulus/**" schedule: @@ -95,11 +99,18 @@ jobs: exit 1 fi + edge_ref_delimiter="edge_ref_$RANDOM$RANDOM" + stage_ref_delimiter="stage_ref_$RANDOM$RANDOM" + { echo "edge_repository=$edge_repository" - echo "edge_ref=$edge_ref" + echo "edge_ref<<$edge_ref_delimiter" + echo "$edge_ref" + echo "$edge_ref_delimiter" echo "stage_repository=$stage_repository" - echo "stage_ref=$stage_ref" + echo "stage_ref<<$stage_ref_delimiter" + echo "$stage_ref" + echo "$stage_ref_delimiter" } >> "$GITHUB_OUTPUT" build: @@ -153,7 +164,12 @@ jobs: cp "$GITHUB_WORKSPACE/tooling/frontend/tsdoc.json" tsdoc.json mkdir -p tooling cp -R "$GITHUB_WORKSPACE/tooling/frontend/tooling/typedoc" tooling/ - npm install --no-save --ignore-scripts typedoc@0.28.20 typedoc-github-theme@0.4.0 typedoc-plugin-rename-defaults@0.7.3 + lock="$GITHUB_WORKSPACE/tooling/frontend/package-lock.json" + version() { jq -r ".packages[\"node_modules/$1\"].version" "$lock"; } + npm install --no-save --ignore-scripts \ + "typedoc@$(version typedoc)" \ + "typedoc-github-theme@$(version typedoc-github-theme)" \ + "typedoc-plugin-rename-defaults@$(version typedoc-plugin-rename-defaults)" - name: Generate TypeDoc env: @@ -163,6 +179,11 @@ jobs: run: | output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" sha=$(git -C "$GITHUB_WORKSPACE/source" rev-parse HEAD) + # {path} is relative to TypeDoc's inferred basePath (the entry + # points' common ancestor, currently src/stimulus). Pinning + # basePath explicitly renames every generated page, so this + # prefix must be kept in sync with entryPoints in typedoc.json + # by hand instead. template="https://github.com/$SOURCE_REPOSITORY/blob/$sha/frontend/src/stimulus/{path}#L{line}" jq --arg template "$template" '.sourceLinkTemplate = $template | del(.gitRevision)' \ typedoc.ci.json > typedoc.ci.next.json @@ -175,16 +196,27 @@ jobs: run: | set -euo pipefail output="$GITHUB_WORKSPACE/site/$CHANNEL/javascript" - test -f "$output/classes/controllers_async-dialog.controller.AsyncDialogController.html" - test -f "$output/classes/controllers_dynamic_sortable-lists_list.controller.ListController.html" - test -f "$output/functions/helpers_request-helpers.post.html" - test -f "$output/functions/mixins_use-angular-services.useAngularServices.html" + + # Page names are only pinned for edge: stage tracks a release + # branch this workflow does not control, and a rename there is + # not this branch's regression to catch. + if [ "$CHANNEL" = "edge" ]; then + test -f "$output/classes/controllers_async-dialog.controller.AsyncDialogController.html" + test -f "$output/classes/controllers_dynamic_sortable-lists_list.controller.ListController.html" + test -f "$output/functions/helpers_request-helpers.post.html" + test -f "$output/functions/mixins_use-angular-services.useAngularServices.html" + fi if find "$output" -type f -print | grep -F '.spec.'; then echo "Error: TypeDoc output contains spec modules" >&2 exit 1 fi + if [ ! -d "$output/modules" ]; then + echo "Error: expected TypeDoc modules directory not found" >&2 + exit 1 + fi + if find "$output/modules" -type f \ \( -name 'app_*' -o -name 'react_*' -o -name 'turbo_*' \) \ -print | grep -q .; then From f32d782fae83ea3654b1258e8f4409ee28180bab Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 5 Sep 2026 15:57:22 +0100 Subject: [PATCH 13/13] [OP-18657] Cover naming and source links in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite exercised the plugin through a fixture harness that omits `TypeDocReader`, so nothing asserted against the configuration actually shipped: dropping a plugin from `typedoc.json` left every test green. Adds a helper that converts real sources through that file, and two tests over it. Both were confirmed to fail under the mutation they guard — the plugin removed, and the source-link template corrupted. https://community.openproject.org/wp/OP-18657 --- .../typedoc/openproject-plugin.spec.mjs | 34 ++++++++++++++++++- frontend/tooling/typedoc/run-typedoc.mjs | 31 ++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/frontend/tooling/typedoc/openproject-plugin.spec.mjs b/frontend/tooling/typedoc/openproject-plugin.spec.mjs index 49e1a913bc72..cffb4ebac234 100644 --- a/frontend/tooling/typedoc/openproject-plugin.spec.mjs +++ b/frontend/tooling/typedoc/openproject-plugin.spec.mjs @@ -28,7 +28,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { buildFixtureProject } from './run-typedoc.mjs'; +import { buildFixtureProject, buildFromRepoConfig } from './run-typedoc.mjs'; const plugin = fileURLToPath(new URL('./openproject-plugin.mjs', import.meta.url)); @@ -55,3 +55,35 @@ describe('vendored source stripping', () => { expect(firstParty.length).toBeGreaterThan(0); }); }); + +describe('shipped typedoc.json', () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + // Two entry points in different subdirectories, so TypeDoc infers the same + // base path (`src/stimulus`) that the full build does. A single entry point + // would shift it and silently drop a path segment from every source link. + const entryPoints = ['src/stimulus/controllers/check-all.controller.ts', 'src/stimulus/helpers/url-helpers.ts']; + + it('names default-exported controllers after their class', async () => { + const project = await buildFromRepoConfig({ entryPoints, gitRevision: revision }); + const names = Object.values(project.reflections).map((reflection) => reflection.name); + + expect(names).toContain('CheckAllController'); + expect(names).not.toContain('default'); + }); + + it('builds source links from the given revision and repository-relative path', async () => { + const project = await buildFromRepoConfig({ entryPoints, gitRevision: revision }); + const urls = allSources(project).map((source) => source.url).filter(Boolean); + + expect(urls.length).toBeGreaterThan(0); + for (const url of urls) { + expect(url).toMatch( + new RegExp(`/blob/${revision}/frontend/src/stimulus/[\\w./-]+\\.ts#L\\d+$`), + ); + } + + expect(urls).toContainEqual( + expect.stringContaining('frontend/src/stimulus/controllers/check-all.controller.ts'), + ); + }); +}); diff --git a/frontend/tooling/typedoc/run-typedoc.mjs b/frontend/tooling/typedoc/run-typedoc.mjs index ac514e04c45d..a5a7c418c31c 100644 --- a/frontend/tooling/typedoc/run-typedoc.mjs +++ b/frontend/tooling/typedoc/run-typedoc.mjs @@ -27,10 +27,11 @@ //++ import { fileURLToPath } from 'node:url'; -import { Application, PackageJsonReader, TSConfigReader } from 'typedoc'; +import { Application, PackageJsonReader, TSConfigReader, TypeDocReader } from 'typedoc'; const fixturesRoot = fileURLToPath(new URL('./__fixtures__/', import.meta.url)); const toolingTsconfig = fileURLToPath(new URL('./tsconfig.json', import.meta.url)); +const frontendRoot = fileURLToPath(new URL('../../', import.meta.url)); // Skip TypeDocReader so the repo's own `typedoc.json` (scoped to // `src/stimulus/**`) never leaks into fixture conversions. @@ -59,3 +60,31 @@ export async function buildFixtureProject({ fixture, plugins = [], options = {} return project; } + +/** + * Converts real sources using the repository's own `typedoc.json`. + * + * `buildFixtureProject` deliberately omits `TypeDocReader`, so a test using it + * proves nothing about the shipped configuration — a plugin dropped from + * `typedoc.json` would still be loaded if the test passed it explicitly. This + * helper reads that file the way CI and `npm run generate-docs` do, overriding + * only what a test needs to stay fast and deterministic. + * + * @param options - Entry points to convert and the revision for source links + * @returns The converted project reflection + */ +export async function buildFromRepoConfig({ entryPoints, gitRevision }) { + const app = await Application.bootstrapWithPlugins({ + options: frontendRoot, + entryPoints: entryPoints.map((entry) => `${frontendRoot}${entry}`), + gitRevision, + logLevel: 'Error', + }, [new TypeDocReader(), new PackageJsonReader(), new TSConfigReader()]); + + const project = await app.convert(); + if (!project) { + throw new Error(`TypeDoc failed to convert ${entryPoints.join(', ')}`); + } + + return project; +}