diff --git a/.github/workflows/frontend-api-docs.yml b/.github/workflows/frontend-api-docs.yml new file mode 100644 index 000000000000..4c892822230f --- /dev/null +++ b/.github/workflows/frontend-api-docs.yml @@ -0,0 +1,371 @@ +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/tooling/**" + - "frontend/tsdoc.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/tooling/**" + - "frontend/tsdoc.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 + + edge_ref_delimiter="edge_ref_$RANDOM$RANDOM" + stage_ref_delimiter="stage_ref_$RANDOM$RANDOM" + + { + echo "edge_repository=$edge_repository" + echo "edge_ref<<$edge_ref_delimiter" + echo "$edge_ref" + echo "$edge_ref_delimiter" + echo "stage_repository=$stage_repository" + echo "stage_ref<<$stage_ref_delimiter" + echo "$stage_ref" + echo "$stage_ref_delimiter" + } >> "$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 + cp "$GITHUB_WORKSPACE/tooling/frontend/tsdoc.json" tsdoc.json + mkdir -p tooling + cp -R "$GITHUB_WORKSPACE/tooling/frontend/tooling/typedoc" tooling/ + 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: + 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) + # {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 + mv typedoc.ci.next.json typedoc.ci.json + ./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" + + # 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 + 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.

    + +
    + + + `); + 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 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 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: =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", @@ -9824,6 +9967,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 +12207,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 +12516,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 +12921,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 +12980,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 +13042,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 +14612,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 +16454,95 @@ "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-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", + "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 +16580,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..bda6ba839a8b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,12 +44,16 @@ "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", + "typedoc-plugin-rename-defaults": "^0.7.3", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0", "vitest": "^4.1.10", @@ -195,9 +199,11 @@ "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", + "generate-docs": "npm run ci:plugins:register_frontend && typedoc --gitRevision $(git rev-parse HEAD)", "postinstall": "patch-package" }, "allowScripts": { 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); 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/__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..cffb4ebac234 --- /dev/null +++ b/frontend/tooling/typedoc/openproject-plugin.spec.mjs @@ -0,0 +1,89 @@ +//-- 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, buildFromRepoConfig } 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); + }); +}); + +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 new file mode 100644 index 000000000000..a5a7c418c31c --- /dev/null +++ b/frontend/tooling/typedoc/run-typedoc.mjs @@ -0,0 +1,90 @@ +//-- 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, 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. +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; +} + +/** + * 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; +} 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/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"] +} diff --git a/frontend/typedoc.json b/frontend/typedoc.json new file mode 100644 index 000000000000..fd1f8d6fac4e --- /dev/null +++ b/frontend/typedoc.json @@ -0,0 +1,23 @@ +{ + "entryPoints": ["src/stimulus/**/*.ts"], + "entryPointStrategy": "expand", + "exclude": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "src/stimulus/setup.ts", + "src/stimulus/openproject-stimulus-application.ts", + "src/stimulus/test-helpers.ts" + ], + "plugin": [ + "typedoc-github-theme", + "typedoc-plugin-rename-defaults", + "./tooling/typedoc/openproject-plugin.mjs" + ], + "excludeExternals": true, + "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" +} 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, + }, +});