diff --git a/.env.example b/.env.example index 239f191a..af803bee 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ FRAMNA_DOCS_BASE_URL=http://localhost:3000 FRAMNA_DOCS_TITLE=Framna Docs FRAMNA_DOCS_DESCRIPTION=Documentation for Framna's APIs FRAMNA_DOCS_HELP_URL=https://github.com/shapehq/framna-docs/wiki -FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME=.shape-docs.yml +FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME=.framna-docs.yml NEXTAUTH_URL=https://docs.example.com NEXTAUTH_SECRET=use [openssl rand -base64 32] to generate a 32 bytes value REDIS_URL=localhost @@ -24,3 +24,4 @@ GITHUB_APP_ID=123456 GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key +NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR=true diff --git a/.github/workflows/check-changes-to-env.yml b/.github/workflows/check-changes-to-env.yml index 61d982b9..bc67d682 100644 --- a/.github/workflows/check-changes-to-env.yml +++ b/.github/workflows/check-changes-to-env.yml @@ -1,7 +1,7 @@ name: Check Changes to Env permissions: contents: read - pull-requests: read + pull-requests: write issues: write on: pull_request: diff --git a/Dockerfile b/Dockerfile index b24c6764..671b45aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,11 @@ FROM node:24-alpine AS base +FROM base AS oasdiff +ARG OASDIFF_VERSION=2.10.0 +RUN apk add --no-cache curl tar ca-certificates +RUN curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh \ + | sh -s -- -b /usr/local/bin "v${OASDIFF_VERSION}" + # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. @@ -46,6 +52,7 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public +COPY --from=oasdiff /usr/local/bin/oasdiff /usr/local/bin/oasdiff # Set the correct permission for prerender cache RUN mkdir .next diff --git a/README.md b/README.md index d5695df4..dda1ecea 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,17 @@ Please refer to the following articles in [the wiki](https://github.com/shapehq/ - [Updating Documentation](https://github.com/shapehq/framna-docs/wiki/Updating-Documentation) - [Deploying Framna Docs](https://github.com/shapehq/framna-docs/wiki/Deploying-Framna-Docs) +### Install the OpenAPI diff tool locally + +Framna Docs relies on the [`oasdiff`](https://github.com/oasdiff/oasdiff) CLI when comparing specifications. + +On MacOS you can install with homebrew: + +```bash +brew tap oasdiff/homebrew-oasdiff +brew install oasdiff +``` + ## 👨‍🔧 How does it work? Framna Docs uses [OpenAPI specifications](https://swagger.io) from GitHub repositories. Users log in with their GitHub account to access documentation for projects they have access to. A repository only needs an OpenAPI spec to be recognized by Framna Docs, but customization is possible with a .framna-docs.yml file. Here's an example: diff --git a/__test__/diff/OasDiffCalculator.test.ts b/__test__/diff/OasDiffCalculator.test.ts new file mode 100644 index 00000000..cbe4932f --- /dev/null +++ b/__test__/diff/OasDiffCalculator.test.ts @@ -0,0 +1,142 @@ +import { OasDiffCalculator } from "../../src/features/diff/data/OasDiffCalculator" +import IGitHubClient from "../../src/common/github/IGitHubClient" + +const createMockGitHubClient = ( + baseUrl: string, + headUrl: string, + mergeBaseSha = "abc123" +): IGitHubClient => ({ + async compareCommitsWithBasehead() { + return { mergeBaseSha } + }, + async getRepositoryContent(request) { + if (request.ref === mergeBaseSha) { + return { downloadURL: baseUrl } + } + return { downloadURL: headUrl } + }, + async graphql() { + return {} + }, + async getPullRequestFiles() { + return [] + }, + async getPullRequestComments() { + return [] + }, + async addCommentToPullRequest() {}, + async updatePullRequestComment() {} +}) + +test("It rejects non-GitHub URLs for base spec", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://malicious-site.com/file.yaml", + "https://raw.githubusercontent.com/owner/repo/main/file.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Invalid URL for base spec") +}) + +test("It rejects invalid URLs", async () => { + const mockGitHubClient = createMockGitHubClient( + "not-a-valid-url", + "https://raw.githubusercontent.com/owner/repo/main/file.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Invalid URL for base spec") +}) + +test("It accepts raw.githubusercontent.com URLs", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://raw.githubusercontent.com/owner/repo/main/file1.yaml", + "https://raw.githubusercontent.com/owner/repo/main/file2.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + // This will fail when trying to execute oasdiff, but that's expected + // We're only testing that URL validation passes + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Failed to execute OpenAPI diff tool") +}) + +test("It accepts github.com URLs", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://github.com/owner/repo/raw/main/file1.yaml", + "https://github.com/owner/repo/raw/main/file2.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + // This will fail when trying to execute oasdiff, but that's expected + // We're only testing that URL validation passes + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Failed to execute OpenAPI diff tool") +}) + +test("It accepts api.github.com URLs", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://api.github.com/repos/owner/repo/contents/file1.yaml", + "https://api.github.com/repos/owner/repo/contents/file2.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + // This will fail when trying to execute oasdiff, but that's expected + // We're only testing that URL validation passes + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Failed to execute OpenAPI diff tool") +}) + +test("It rejects URLs with GitHub-like subdomains but different domains", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://raw.githubusercontent.com.evil.com/file.yaml", + "https://raw.githubusercontent.com/owner/repo/main/file.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Invalid URL for base spec") +}) + +test("It validates both base and head URLs", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://raw.githubusercontent.com/owner/repo/main/file1.yaml", + "https://malicious-site.com/file.yaml" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + await expect( + calculator.calculateDiff("owner", "repo", "path.yaml", "base", "head") + ).rejects.toThrow("Invalid URL for head spec") +}) + +test("It returns empty changes when comparing same refs", async () => { + const mockGitHubClient = createMockGitHubClient( + "https://raw.githubusercontent.com/owner/repo/main/file1.yaml", + "https://raw.githubusercontent.com/owner/repo/main/file2.yaml", + "abc123" + ) + const calculator = new OasDiffCalculator(mockGitHubClient) + + const result = await calculator.calculateDiff( + "owner", + "repo", + "path.yaml", + "abc123", + "abc123" + ) + + expect(result).toEqual({ + from: "abc123", + to: "abc123", + changes: [] + }) +}) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 12438c76..586375bc 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -845,11 +845,13 @@ test("It adds remote versions from the project configuration", async () => { id: "huey", name: "Huey", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, + urlHash: "89ba381286214eec", isDefault: false }, { id: "dewey", name: "Dewey", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, + urlHash: "8f810fff152505f6", isDefault: false }] }, { @@ -860,6 +862,7 @@ test("It adds remote versions from the project configuration", async () => { id: "louie", name: "Louie", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, + urlHash: "b83ebf43ceede6bc", isDefault: false }] }]) @@ -925,6 +928,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { id: "baz", name: "Baz", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + urlHash: "25cb42ff63570cb5", isDefault: false }] }, { @@ -935,6 +939,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { id: "hello", name: "Hello", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, + urlHash: "d078bd689699d1f0", isDefault: false }] }]) @@ -979,6 +984,7 @@ test("It lets users specify the ID of a remote version", async () => { id: "baz", name: "Baz", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + urlHash: "25cb42ff63570cb5", isDefault: false }] }]) @@ -1023,6 +1029,7 @@ test("It lets users specify the ID of a remote specification", async () => { id: "some-spec", name: "Baz", url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + urlHash: "25cb42ff63570cb5", isDefault: false }] }]) diff --git a/package-lock.json b/package-lock.json index e5b16364..5db706a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,52 +16,52 @@ "@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", - "@mui/icons-material": "^7.3.5", + "@mui/icons-material": "^7.3.6", "@mui/material": "^7.0.1", "@octokit/auth-app": "^8.1.2", "@octokit/core": "^7.0.6", - "@octokit/webhooks": "~14.1.3", + "@octokit/webhooks": "~14.2.0", "core-js": "^3.47.0", "encoding": "^0.1.13", "figma-squircle": "^1.1.0", "install": "^0.13.0", "ioredis": "^5.8.2", "mobx": "^6.15.0", - "next": "16.0.7", + "next": "16.1.1", "next-auth": "^5.0.0-beta.30", - "npm": "^11.6.4", + "npm": "^11.7.0", "nprogress": "^0.2.0", "octokit": "^5.0.5", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "redis-semaphore": "^5.6.2", "redoc": "^2.5.2", "sharp": "^0.34.2", "styled-components": "^6.1.19", - "swr": "^2.3.7", + "swr": "^2.3.8", "usehooks-ts": "^3.1.1", "yaml": "^2.8.2", - "zod": "^4.1.13" + "zod": "^4.3.4" }, "devDependencies": { "@auth/pg-adapter": "^1.11.1", - "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/postcss": "^4.1.18", "@types/jest": "^30.0.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/nprogress": "^0.2.3", - "@types/pg": "^8.15.6", + "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^9.39.1", - "eslint-config-next": "^16.0.6", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.1", "pg": "^8.16.3", "postcss": "^8.5.6", "tailwindcss": "^4.1.4", - "ts-jest": "^29.4.5", + "ts-jest": "^29.4.6", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.0" + "typescript-eslint": "^8.51.0" }, "engines": { "node": ">=24.0.0 <25.0.0", @@ -981,9 +981,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -2157,9 +2157,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz", - "integrity": "sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz", + "integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==", "license": "MIT", "funding": { "type": "opencollective", @@ -2167,9 +2167,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz", - "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.6.tgz", + "integrity": "sha512-0FfkXEj22ysIq5pa41A2NbcAhJSvmcZQ/vcTIbjDsd6hlslG82k5BEBqqS0ZJprxwIL3B45qpJ+bPHwJPlF7uQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4" @@ -2182,7 +2182,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.3.5", + "@mui/material": "^7.3.6", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2193,17 +2193,17 @@ } }, "node_modules/@mui/material": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", - "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", + "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", - "@mui/core-downloads-tracker": "^7.3.5", - "@mui/system": "^7.3.5", - "@mui/types": "^7.4.8", - "@mui/utils": "^7.3.5", + "@mui/core-downloads-tracker": "^7.3.6", + "@mui/system": "^7.3.6", + "@mui/types": "^7.4.9", + "@mui/utils": "^7.3.6", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", @@ -2222,7 +2222,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.5", + "@mui/material-pigment-css": "^7.3.6", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2243,13 +2243,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.5.tgz", - "integrity": "sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz", + "integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", - "@mui/utils": "^7.3.5", + "@mui/utils": "^7.3.6", "prop-types": "^15.8.1" }, "engines": { @@ -2270,9 +2270,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.5.tgz", - "integrity": "sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz", + "integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -2304,16 +2304,16 @@ } }, "node_modules/@mui/system": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.5.tgz", - "integrity": "sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz", + "integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", - "@mui/private-theming": "^7.3.5", - "@mui/styled-engine": "^7.3.5", - "@mui/types": "^7.4.8", - "@mui/utils": "^7.3.5", + "@mui/private-theming": "^7.3.6", + "@mui/styled-engine": "^7.3.6", + "@mui/types": "^7.4.9", + "@mui/utils": "^7.3.6", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -2344,9 +2344,9 @@ } }, "node_modules/@mui/types": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", - "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz", + "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4" @@ -2361,13 +2361,13 @@ } }, "node_modules/@mui/utils": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", - "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz", + "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", - "@mui/types": "^7.4.8", + "@mui/types": "^7.4.9", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -2404,15 +2404,15 @@ } }, "node_modules/@next/env": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", - "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.6.tgz", - "integrity": "sha512-9INsBF3/4XL0/tON8AGsh0svnTtDMLwv3iREGWnWkewGdOnd790tguzq9rX8xwrVthPyvaBHhw1ww0GZz0jO5Q==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", + "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", "dependencies": { @@ -2420,9 +2420,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", - "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", "cpu": [ "arm64" ], @@ -2436,9 +2436,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", - "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", "cpu": [ "x64" ], @@ -2452,9 +2452,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", - "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", "cpu": [ "arm64" ], @@ -2468,9 +2468,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", - "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", "cpu": [ "arm64" ], @@ -2484,9 +2484,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", - "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", "cpu": [ "x64" ], @@ -2500,9 +2500,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", - "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", "cpu": [ "x64" ], @@ -2516,9 +2516,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", - "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", "cpu": [ "arm64" ], @@ -2532,9 +2532,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", - "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", "cpu": [ "x64" ], @@ -2797,9 +2797,9 @@ "license": "MIT" }, "node_modules/@octokit/openapi-webhooks-types": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.0.3.tgz", - "integrity": "sha512-90MF5LVHjBedwoHyJsgmaFhEN1uzXyBDRLEBe7jlTYx/fEhPAk3P3DAJsfZwC54m8hAIryosJOL+UuZHB3K3yA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.1.0.tgz", + "integrity": "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==", "license": "MIT" }, "node_modules/@octokit/plugin-paginate-graphql": { @@ -2915,12 +2915,12 @@ } }, "node_modules/@octokit/webhooks": { - "version": "14.1.3", - "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.1.3.tgz", - "integrity": "sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.2.0.tgz", + "integrity": "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==", "license": "MIT", "dependencies": { - "@octokit/openapi-webhooks-types": "12.0.3", + "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" }, @@ -3085,9 +3085,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3097,37 +3097,37 @@ "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -3142,9 +3142,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -3159,9 +3159,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -3176,9 +3176,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -3193,9 +3193,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -3210,9 +3210,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], @@ -3227,9 +3227,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -3244,9 +3244,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -3261,9 +3261,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -3278,9 +3278,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3296,10 +3296,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -3308,7 +3308,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", + "version": "1.7.1", "dev": true, "inBundle": true, "license": "MIT", @@ -3319,7 +3319,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", + "version": "1.7.1", "dev": true, "inBundle": true, "license": "MIT", @@ -3339,14 +3339,14 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", + "version": "1.1.0", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, @@ -3368,9 +3368,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -3385,9 +3385,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -3402,17 +3402,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", - "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tybys/wasm-util": { @@ -3536,9 +3536,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", "dependencies": { @@ -3559,9 +3559,9 @@ "license": "MIT" }, "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3643,21 +3643,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3667,23 +3666,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", + "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "engines": { @@ -3699,14 +3698,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "engines": { @@ -3721,14 +3720,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3739,9 +3738,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", "dev": true, "license": "MIT", "engines": { @@ -3756,17 +3755,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3781,9 +3780,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", "dev": true, "license": "MIT", "engines": { @@ -3795,21 +3794,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3823,16 +3822,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3847,13 +3846,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4614,7 +4613,6 @@ "version": "2.8.28", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -5388,9 +5386,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5615,9 +5613,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "peer": true, @@ -5628,7 +5626,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5676,13 +5674,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.6.tgz", - "integrity": "sha512-nx0Z2S50TlcSQ2RtyULCff5tlKTwqF/ICh3U9s8C/e2aRXAm1Ootdb7BEHGZmejtJSgsFq8PVFdlWy8BHiz2pg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", + "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.0.6", + "@next/eslint-plugin-next": "16.1.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6378,9 +6376,9 @@ } }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -6780,13 +6778,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -9052,13 +9043,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", - "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", "dependencies": { - "@next/env": "16.0.7", + "@next/env": "16.1.1", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -9070,14 +9062,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.7", - "@next/swc-darwin-x64": "16.0.7", - "@next/swc-linux-arm64-gnu": "16.0.7", - "@next/swc-linux-arm64-musl": "16.0.7", - "@next/swc-linux-x64-gnu": "16.0.7", - "@next/swc-linux-x64-musl": "16.0.7", - "@next/swc-win32-arm64-msvc": "16.0.7", - "@next/swc-win32-x64-msvc": "16.0.7", + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { @@ -9253,9 +9245,9 @@ } }, "node_modules/npm": { - "version": "11.6.4", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.4.tgz", - "integrity": "sha512-ERjKtGoFpQrua/9bG0+h3xiv/4nVdGViCjUYA1AmlV24fFvfnSB7B7dIfZnySQ1FDLd0ZVrWPsLLp78dCtJdRQ==", + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.7.0.tgz", + "integrity": "sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -9334,8 +9326,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.1.8", - "@npmcli/config": "^10.4.4", + "@npmcli/arborist": "^9.1.9", + "@npmcli/config": "^10.4.5", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -9360,11 +9352,11 @@ "is-cidr": "^6.0.1", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.0.11", - "libnpmexec": "^10.1.10", - "libnpmfund": "^7.0.11", + "libnpmdiff": "^8.0.12", + "libnpmexec": "^10.1.11", + "libnpmfund": "^7.0.12", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.0.11", + "libnpmpack": "^9.0.12", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -9472,7 +9464,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.8", + "version": "9.1.9", "inBundle": true, "license": "ISC", "dependencies": { @@ -9518,7 +9510,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.4", + "version": "10.4.5", "inBundle": true, "license": "ISC", "dependencies": { @@ -10256,11 +10248,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.11", + "version": "8.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -10274,11 +10266,11 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.10", + "version": "10.1.11", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -10296,11 +10288,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.11", + "version": "7.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8" + "@npmcli/arborist": "^9.1.9" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -10319,11 +10311,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.11", + "version": "9.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -12172,9 +12164,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "peer": true, "engines": { @@ -12182,22 +12174,22 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.3" } }, "node_modules/react-is": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", - "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "license": "MIT" }, "node_modules/react-tabs": { @@ -13438,13 +13430,13 @@ } }, "node_modules/swr": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.7.tgz", - "integrity": "sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz", + "integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==", "license": "MIT", "dependencies": { "dequal": "^2.0.3", - "use-sync-external-store": "^1.4.0" + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13467,9 +13459,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, "license": "MIT" }, @@ -13633,9 +13625,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", + "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", "dev": true, "license": "MIT", "engines": { @@ -13646,9 +13638,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -13883,16 +13875,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", - "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", + "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0" + "@typescript-eslint/eslint-plugin": "8.51.0", + "@typescript-eslint/parser": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14479,9 +14471,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", + "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 7a8667be..3a04e48a 100644 --- a/package.json +++ b/package.json @@ -23,51 +23,51 @@ "@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", - "@mui/icons-material": "^7.3.5", + "@mui/icons-material": "^7.3.6", "@mui/material": "^7.0.1", "@octokit/auth-app": "^8.1.2", "@octokit/core": "^7.0.6", - "@octokit/webhooks": "~14.1.3", + "@octokit/webhooks": "~14.2.0", "core-js": "^3.47.0", "encoding": "^0.1.13", "figma-squircle": "^1.1.0", "install": "^0.13.0", "ioredis": "^5.8.2", "mobx": "^6.15.0", - "next": "16.0.7", + "next": "16.1.1", "next-auth": "^5.0.0-beta.30", - "npm": "^11.6.4", + "npm": "^11.7.0", "nprogress": "^0.2.0", "octokit": "^5.0.5", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "redis-semaphore": "^5.6.2", "redoc": "^2.5.2", "sharp": "^0.34.2", "styled-components": "^6.1.19", - "swr": "^2.3.7", + "swr": "^2.3.8", "usehooks-ts": "^3.1.1", "yaml": "^2.8.2", - "zod": "^4.1.13" + "zod": "^4.3.4" }, "devDependencies": { "@auth/pg-adapter": "^1.11.1", - "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/postcss": "^4.1.18", "@types/jest": "^30.0.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/nprogress": "^0.2.3", - "@types/pg": "^8.15.6", - "@typescript-eslint/eslint-plugin": "^8.48.0", + "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/parser": "^8.48.0", - "typescript-eslint": "^8.48.0", - "eslint": "^9.39.1", - "eslint-config-next": "^16.0.6", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "typescript-eslint": "^8.51.0", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.1", "pg": "^8.16.3", "postcss": "^8.5.6", "tailwindcss": "^4.1.4", - "ts-jest": "^29.4.5", + "ts-jest": "^29.4.6", "typescript": "^5.9.3" } } diff --git a/src/app/(authed)/(project-doc)/[...slug]/page.tsx b/src/app/(authed)/(project-doc)/[...slug]/page.tsx index 66bb1423..02473315 100644 --- a/src/app/(authed)/(project-doc)/[...slug]/page.tsx +++ b/src/app/(authed)/(project-doc)/[...slug]/page.tsx @@ -36,8 +36,8 @@ export default function Page() { {project && (!version || !specification) && !refreshing && } - {refreshing && // project data is currently being fetched - show loading indicator - + {!project && !version && !specification && refreshing && + } {!project && !refreshing && } diff --git a/src/app/api/diff/[owner]/[repository]/[...path]/route.ts b/src/app/api/diff/[owner]/[repository]/[...path]/route.ts new file mode 100644 index 00000000..7486ce4d --- /dev/null +++ b/src/app/api/diff/[owner]/[repository]/[...path]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server" +import { session, diffCalculator } from "@/composition" +import { makeUnauthenticatedAPIErrorResponse } from "@/common" + +interface GetDiffParams { + owner: string + repository: string + path: string[] +} + +export async function GET(req: NextRequest, { params }: { params: Promise }) { + const isAuthenticated = await session.getIsAuthenticated() + if (!isAuthenticated) { + return makeUnauthenticatedAPIErrorResponse() + } + + const { path: paramsPath, owner, repository } = await params + const path = paramsPath.join("/") + + const toRef = req.nextUrl.searchParams.get("to") + const baseRefOid = req.nextUrl.searchParams.get("baseRefOid") + + if (!toRef) { + return NextResponse.json({ error: "Missing 'to' parameter" }, { status: 400 }) + } + + if (!baseRefOid) { + return NextResponse.json({ error: "Missing 'baseRefOid' parameter" }, { status: 400 }) + } + + try { + const diff = await diffCalculator.calculateDiff( + owner, + repository, + path, + baseRefOid, + toRef + ) + + return NextResponse.json(diff) + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error while calculating diff" + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/src/common/github/GitHubClient.ts b/src/common/github/GitHubClient.ts index 3ecf10e1..6a47674f 100644 --- a/src/common/github/GitHubClient.ts +++ b/src/common/github/GitHubClient.ts @@ -8,6 +8,8 @@ import IGitHubClient, { GetPullRequestFilesRequest, AddCommentToPullRequestRequest, UpdatePullRequestCommentRequest, + CompareCommitsRequest, + CompareCommitsResponse, RepositoryContent, PullRequestComment, PullRequestFile @@ -119,4 +121,15 @@ export default class GitHubClient implements IGitHubClient { body: request.body }) } + + async compareCommitsWithBasehead(request: CompareCommitsRequest): Promise { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + const octokit = new Octokit({ auth: oauthToken.accessToken }) + const response = await octokit.rest.repos.compareCommitsWithBasehead({ + owner: request.repositoryOwner, + repo: request.repositoryName, + basehead: `${request.baseRefOid}...${request.headRefOid}` + }) + return { mergeBaseSha: response.data.merge_base_commit.sha } + } } diff --git a/src/common/github/IGitHubClient.ts b/src/common/github/IGitHubClient.ts index a739f69c..601de84b 100644 --- a/src/common/github/IGitHubClient.ts +++ b/src/common/github/IGitHubClient.ts @@ -70,6 +70,17 @@ export type UpdatePullRequestCommentRequest = { readonly body: string } +export type CompareCommitsRequest = { + readonly repositoryOwner: string + readonly repositoryName: string + readonly baseRefOid: string + readonly headRefOid: string +} + +export type CompareCommitsResponse = { + readonly mergeBaseSha: string +} + export default interface IGitHubClient { graphql(request: GraphQLQueryRequest): Promise getRepositoryContent(request: GetRepositoryContentRequest): Promise @@ -77,4 +88,5 @@ export default interface IGitHubClient { getPullRequestComments(request: GetPullRequestCommentsRequest): Promise addCommentToPullRequest(request: AddCommentToPullRequestRequest): Promise updatePullRequestComment(request: UpdatePullRequestCommentRequest): Promise + compareCommitsWithBasehead(request: CompareCommitsRequest): Promise } diff --git a/src/common/github/OAuthTokenRefreshingGitHubClient.ts b/src/common/github/OAuthTokenRefreshingGitHubClient.ts index 48b6373e..ad33dd8a 100644 --- a/src/common/github/OAuthTokenRefreshingGitHubClient.ts +++ b/src/common/github/OAuthTokenRefreshingGitHubClient.ts @@ -7,6 +7,8 @@ import IGitHubClient, { AddCommentToPullRequestRequest, UpdatePullRequestCommentRequest, GetPullRequestFilesRequest, + CompareCommitsRequest, + CompareCommitsResponse, RepositoryContent, PullRequestComment, PullRequestFile @@ -76,7 +78,13 @@ export default class OAuthTokenRefreshingGitHubClient implements IGitHubClient { return await this.gitHubClient.updatePullRequestComment(request) }) } - + + async compareCommitsWithBasehead(request: CompareCommitsRequest): Promise { + return await this.send(async () => { + return await this.gitHubClient.compareCommitsWithBasehead(request) + }) + } + private async send(fn: () => Promise): Promise { const oauthToken = await this.oauthTokenDataSource.getOAuthToken() try { diff --git a/src/common/github/RepoRestrictedGitHubClient.ts b/src/common/github/RepoRestrictedGitHubClient.ts index 558492f3..eb15dd5c 100644 --- a/src/common/github/RepoRestrictedGitHubClient.ts +++ b/src/common/github/RepoRestrictedGitHubClient.ts @@ -1,15 +1,17 @@ -import { - IGitHubClient, - AddCommentToPullRequestRequest, - GetPullRequestCommentsRequest, - GetPullRequestFilesRequest, - GetRepositoryContentRequest, - GraphQLQueryRequest, - GraphQlQueryResponse, - PullRequestComment, - PullRequestFile, - RepositoryContent, - UpdatePullRequestCommentRequest +import { + IGitHubClient, + AddCommentToPullRequestRequest, + GetPullRequestCommentsRequest, + GetPullRequestFilesRequest, + GetRepositoryContentRequest, + GraphQLQueryRequest, + GraphQlQueryResponse, + PullRequestComment, + PullRequestFile, + RepositoryContent, + UpdatePullRequestCommentRequest, + CompareCommitsRequest, + CompareCommitsResponse } from "@/common"; export class RepoRestrictedGitHubClient implements IGitHubClient { @@ -54,6 +56,11 @@ export class RepoRestrictedGitHubClient implements IGitHubClient { return this.gitHubClient.updatePullRequestComment(request); } + compareCommitsWithBasehead(request: CompareCommitsRequest): Promise { + if (!this.isRepositoryNameValid(request.repositoryName)) return Promise.reject(new Error("Invalid repository name")); + return this.gitHubClient.compareCommitsWithBasehead(request); + } + private isRepositoryNameValid(repositoryName: string): boolean { return repositoryName.endsWith(this.repositoryNameSuffix); } diff --git a/src/common/ui/HighlightText.tsx b/src/common/ui/HighlightText.tsx index db6570e1..67ec7fe8 100644 --- a/src/common/ui/HighlightText.tsx +++ b/src/common/ui/HighlightText.tsx @@ -1,5 +1,5 @@ "use client" -import React from "react" + import { SxProps, Typography, TypographyVariant } from "@mui/material" import styled from "@emotion/styled" diff --git a/src/common/ui/SpacedList.tsx b/src/common/ui/SpacedList.tsx index 313acaef..1aa2b11a 100644 --- a/src/common/ui/SpacedList.tsx +++ b/src/common/ui/SpacedList.tsx @@ -1,21 +1,25 @@ import React from "react" import { List, Box, SxProps } from "@mui/material" -const SpacedList = ({ - itemSpacing, - sx, - children -}: { +interface SpacedListProps { itemSpacing: number sx?: SxProps children?: React.ReactNode -}) => { +} + +const SpacedList = ({ itemSpacing, sx, children }: SpacedListProps) => { + const childrenArray = React.Children.toArray(children) + const lastIndex = childrenArray.length - 1 + return ( - - {React.Children.map(children, (child, idx) => ( - + + {childrenArray.map((child, idx) => ( + {child} ))} diff --git a/src/composition.ts b/src/composition.ts index 10f4e504..8187b966 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -53,6 +53,8 @@ import { import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHubClient" import RsaEncryptionService from "./features/encrypt/EncryptionService" import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder" +import { OasDiffCalculator } from "./features/diff/data/OasDiffCalculator" +import { IOasDiffCalculator } from "./features/diff/data/IOasDiffCalculator" const gitHubAppCredentials = { appId: env.getOrThrow("GITHUB_APP_ID"), @@ -230,4 +232,6 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) \ No newline at end of file +}) + +export const diffCalculator: IOasDiffCalculator = new OasDiffCalculator(gitHubClient) diff --git a/src/features/diff/data/IOasDiffCalculator.ts b/src/features/diff/data/IOasDiffCalculator.ts new file mode 100644 index 00000000..92261518 --- /dev/null +++ b/src/features/diff/data/IOasDiffCalculator.ts @@ -0,0 +1,19 @@ +import { DiffChange } from "../domain/DiffChange" + +export interface DiffResult { + from: string + to: string + changes: DiffChange[] + error?: string | null + isNewFile?: boolean +} + +export interface IOasDiffCalculator { + calculateDiff( + owner: string, + repository: string, + path: string, + baseRefOid: string, + toRef: string + ): Promise +} diff --git a/src/features/diff/data/OasDiffCalculator.ts b/src/features/diff/data/OasDiffCalculator.ts new file mode 100644 index 00000000..a15fe67c --- /dev/null +++ b/src/features/diff/data/OasDiffCalculator.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "child_process" +import { DiffChange } from "@/features/diff/domain/DiffChange" +import type { IGitHubClient } from "@/common" +import { DiffResult, IOasDiffCalculator } from "./IOasDiffCalculator" + +/** + * Validates that a URL originates from a trusted GitHub domain. + * @param url - The URL to validate + * @returns true if the URL is from a trusted GitHub domain, false otherwise + */ +function isValidGitHubUrl(url: string): boolean { + try { + const parsedUrl = new URL(url) + const trustedDomains = [ + "raw.githubusercontent.com", + "github.com", + "api.github.com" + ] + return trustedDomains.includes(parsedUrl.hostname) + } catch { + return false + } +} + +export class OasDiffCalculator implements IOasDiffCalculator { + constructor(private readonly githubClient: IGitHubClient) {} + + async calculateDiff( + owner: string, + repository: string, + path: string, + baseRefOid: string, + toRef: string + ): Promise { + // Calculate merge-base for diff + const mergeBaseResult = await this.githubClient.compareCommitsWithBasehead({ + repositoryOwner: owner, + repositoryName: repository, + baseRefOid: baseRefOid, + headRefOid: toRef + }) + const fromRef = mergeBaseResult.mergeBaseSha + + // If comparing same refs, return empty diff + if (fromRef === toRef) { + return { + from: fromRef, + to: toRef, + changes: [] + } + } + + // Fetch spec content from both refs + let spec1 + + try { + spec1 = await this.githubClient.getRepositoryContent({ + repositoryOwner: owner, + repositoryName: repository, + path: path, + ref: fromRef + }) + } catch { + // File doesn't exist in base ref - this is a new file + return { + from: fromRef, + to: toRef, + changes: [], + isNewFile: true + } + } + + const spec2 = await this.githubClient.getRepositoryContent({ + repositoryOwner: owner, + repositoryName: repository, + path: path, + ref: toRef + }) + + // Validate URLs originate from GitHub + if (!isValidGitHubUrl(spec1.downloadURL)) { + throw new Error( + `Invalid URL for base spec: ${spec1.downloadURL}. URL must originate from a trusted GitHub domain.` + ) + } + if (!isValidGitHubUrl(spec2.downloadURL)) { + throw new Error( + `Invalid URL for head spec: ${spec2.downloadURL}. URL must originate from a trusted GitHub domain.` + ) + } + + // Execute oasdiff + const diffData = (() => { + try { + const result = execFileSync( + "oasdiff", + ["changelog", "--format", "json", spec1.downloadURL, spec2.downloadURL], + { encoding: "utf8" } + ) + return JSON.parse(result) as DiffChange[] + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Unknown error when executing OpenAPI diff command" + throw new Error( + `Failed to execute OpenAPI diff tool. Please ensure "oasdiff" is installed and available in PATH. (${errorMessage})` + ) + } + })() + + return { + from: fromRef, + to: toRef, + changes: diffData, + error: null + } + } +} diff --git a/src/features/diff/domain/DiffChange.ts b/src/features/diff/domain/DiffChange.ts new file mode 100644 index 00000000..83c61036 --- /dev/null +++ b/src/features/diff/domain/DiffChange.ts @@ -0,0 +1,14 @@ +import { z } from "zod" + +export const DiffChangeSchema = z.object({ + id: z.string(), + text: z.string(), + level: z.number(), + operation: z.string().optional(), + operationId: z.string().optional(), + path: z.string().optional(), + source: z.string().optional(), + section: z.string().optional(), +}) + +export type DiffChange = z.infer diff --git a/src/features/docs/navigation/index.ts b/src/features/docs/navigation/index.ts new file mode 100644 index 00000000..4a871967 --- /dev/null +++ b/src/features/docs/navigation/index.ts @@ -0,0 +1 @@ +export { scrollToOperation } from "./scrollToOperation" diff --git a/src/features/docs/navigation/scrollToOperation.ts b/src/features/docs/navigation/scrollToOperation.ts new file mode 100644 index 00000000..11abac83 --- /dev/null +++ b/src/features/docs/navigation/scrollToOperation.ts @@ -0,0 +1,108 @@ +import { DocumentationVisualizer } from "@/features/settings/domain" + +/** + * Generates a SwaggerUI-compatible operationId from HTTP method and path. + * SwaggerUI generates IDs in the format: {method}_{path_normalized} + * where path is normalized by replacing / with _ and {param} with _param_ + */ +function generateSwaggerOperationId(method: string, path: string): string { + const normalizedPath = path + .replace(/\//g, "_") + .replace(/\{([^}]+)\}/g, "_$1_") + return `${method.toLowerCase()}${normalizedPath}` +} + +/** + * Finds and scrolls to a SwaggerUI operation element. + * SwaggerUI elements have IDs in the format: operations-{tag}-{operationId} + * Since we may not know the tag, we search for elements containing the operationId. + */ +function scrollToSwaggerOperation(operationId: string): boolean { + const block = Array.from(document.querySelectorAll('[id^="operations-"]')) + .find(el => el.id.endsWith(`-${operationId}`)) + + if (!block) return false + + block.scrollIntoView({ behavior: "smooth", block: "start" }) + + // Only expand if not already open (SwaggerUI adds is-open class to the block itself) + if (!block.classList.contains("is-open")) { + const button = block.querySelector(".opblock-summary-control") + if (button instanceof HTMLElement) { + button.click() + } + } + + return true +} + +/** + * Scrolls to an operation in Redocly by setting the hash on the iframe. + */ +function scrollToRedoclyOperation(operationId?: string, method?: string, path?: string): void { + const iframe = document.querySelector("iframe") + if (!iframe?.contentWindow) { + return + } + + // try direct operationId link first + if (operationId) { + iframe.contentWindow.location.hash = `operation/${operationId}` + return + } + + if (!method || !path) { + return + } + + // fallback to method+path matching + const encodedPath = path.replace(/\//g, "~1") + const links = Array.from( + iframe.contentDocument?.querySelectorAll(`a[href*="${encodedPath}"]`) ?? [] + ) + + for (const link of links) { + const href = link.getAttribute("href") + if (href?.includes(`/${method.toLowerCase()}`) || href?.endsWith(`/${method.toLowerCase()}`)) { + const hash = href.startsWith("#") ? href.substring(1) : href + iframe.contentWindow.location.hash = hash + return + } + } +} + +/** + * Scrolls to an operation in the documentation viewer. + * Each visualizer has its own mechanism for deep linking. + */ +export function scrollToOperation( + visualizer: DocumentationVisualizer, + operationId?: string, + method?: string, + path?: string +): void { + if (!operationId && (!method || !path)) { + return + } + + switch (visualizer) { + case DocumentationVisualizer.SWAGGER: { + // If operationId is not provided, try to generate it from method+path + const swaggerOpId = operationId ?? (method && path ? generateSwaggerOperationId(method, path) : undefined) + if (swaggerOpId) { + scrollToSwaggerOperation(swaggerOpId) + } + break + } + + case DocumentationVisualizer.STOPLIGHT: + if (operationId) { + window.location.hash = `/operations/${operationId}` + } + break + + case DocumentationVisualizer.REDOCLY: + scrollToRedoclyOperation(operationId, method, path) + break + } +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 17086513..9976ac45 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto" import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, @@ -122,6 +123,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { const specifications = ref.files.filter(file => { return this.isOpenAPISpecification(file.name) }).map(file => { + const isFileChanged = ref.changedFiles?.includes(file.name) ?? false return { id: file.name, name: file.name, @@ -131,7 +133,17 @@ export default class GitHubProjectDataSource implements IProjectDataSource { path: file.name, ref: ref.id }), - editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}`, + editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${encodeURIComponent(file.name)}`, + diffURL: isFileChanged ? this.getGitHubDiffURL({ + ownerName, + repositoryName, + path: file.name, + baseRefOid: ref.baseRefOid, + headRefOid: ref.id + }) : undefined, + diffBaseBranch: isFileChanged ? ref.baseRef : undefined, + diffBaseOid: isFileChanged ? ref.baseRefOid : undefined, + diffPrUrl: isFileChanged && ref.prNumber ? `https://github.com/${ownerName}/${repositoryName}/pull/${ref.prNumber}` : undefined, isDefault: false // initial value } }).sort((a, b) => a.name.localeCompare(b.name)) @@ -140,7 +152,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { name: ref.name, specifications: specifications, url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, - isDefault: isDefaultRef || false + isDefault: isDefaultRef || false, } } @@ -161,7 +173,29 @@ export default class GitHubProjectDataSource implements IProjectDataSource { path: string ref: string }): string { - return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` + const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') + return `/api/blob/${ownerName}/${repositoryName}/${encodedPath}?ref=${ref}` + } + + private getGitHubDiffURL({ + ownerName, + repositoryName, + path, + baseRefOid, + headRefOid + }: { + ownerName: string; + repositoryName: string; + path: string; + baseRefOid: string | undefined; + headRefOid: string } + ): string | undefined { + if (!baseRefOid) { + return undefined + } else { + const encodedPath = path.split('/').map(segment => encodeURIComponent(segment)).join('/') + return `/api/diff/${ownerName}/${repositoryName}/${encodedPath}?baseRefOid=${baseRefOid}&to=${headRefOid}` + } } private addRemoteVersions( @@ -185,11 +219,14 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }; const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); + // 16 hex chars (64 bits) - sufficient for change detection, not cryptographic security + const configHash = createHash("sha256").update(JSON.stringify(remoteConfig)).digest("hex").slice(0, 16); return { id: this.makeURLSafeID((e.id || e.name).toLowerCase()), name: e.name, url: `/api/remotes/${encodedRemoteConfig}`, + urlHash: configHash, isDefault: false // initial value }; }) diff --git a/src/features/projects/data/GitHubRepositoryDataSource.ts b/src/features/projects/data/GitHubRepositoryDataSource.ts index 569a4bd1..6a776e34 100644 --- a/src/features/projects/data/GitHubRepositoryDataSource.ts +++ b/src/features/projects/data/GitHubRepositoryDataSource.ts @@ -46,6 +46,14 @@ type GraphQLGitHubRepositoryRef = { } } +type GraphQLPullRequest = { + readonly number: number + readonly headRefName: string + readonly baseRefName: string + readonly baseRefOid: string + readonly changedFiles: string[] +} + export default class GitHubProjectDataSource implements IGitHubRepositoryDataSource { private readonly loginsDataSource: IGitHubLoginDataSource private readonly graphQlClient: IGitHubGraphQLClient @@ -99,9 +107,34 @@ export default class GitHubProjectDataSource implements IGitHubRepositoryDataSou return !alreadyAdded }) }) - .then(repositories => { + .then(async repositories => { + // Fetch PRs for all repositories in a single query + const allPullRequests = await this.getOpenPullRequestsForRepositories( + repositories.map(repo => ({ + owner: repo.owner.login, + name: repo.name + })) + ) + // Map from the internal model to the public model. return repositories.map(repository => { + const repoKey = `${repository.owner.login}/${repository.name}` + const pullRequests = allPullRequests.get(repoKey) || new Map() + + const branches = repository.branches.edges.map(branch => { + const pr = pullRequests.get(branch.node.name) + + return { + id: branch.node.target.oid, + name: branch.node.name, + baseRef: pr?.baseRefName, + baseRefOid: pr?.baseRefOid, + prNumber: pr?.number, + files: branch.node.target.tree.entries, + changedFiles: pr?.changedFiles + } + }) + return { name: repository.name, owner: repository.owner.login, @@ -111,13 +144,7 @@ export default class GitHubProjectDataSource implements IGitHubRepositoryDataSou }, configYml: repository.configYml, configYaml: repository.configYaml, - branches: repository.branches.edges.map(branch => { - return { - id: branch.node.target.oid, - name: branch.node.name, - files: branch.node.target.tree.entries - } - }), + branches: branches, tags: repository.tags.edges.map(branch => { return { id: branch.node.target.oid, @@ -130,6 +157,85 @@ export default class GitHubProjectDataSource implements IGitHubRepositoryDataSou }) } + private async getOpenPullRequestsForRepositories( + repositories: Array<{ owner: string, name: string }> + ): Promise>> { + if (repositories.length === 0) { + return new Map() + } + + // Build a query that fetches PRs for all repositories + const repoQueries = repositories + .map((repo, index) => { + return ` + repo${index}: repository(owner: "${repo.owner}", name: "${repo.name}") { + pullRequests(first: 100, states: [OPEN]) { + edges { + node { + number + headRefName + baseRefName + baseRefOid + files(first: 100) { + nodes { + path + } + } + } + } + } + }` + }) + .join("\n") + + const request = { + query: ` + query PullRequests { + ${repoQueries} + } + `, + variables: {} + } + + const response = await this.graphQlClient.graphql(request) + const allPullRequests = new Map>() + + repositories.forEach((repo, index) => { + const repoKey = `${repo.owner}/${repo.name}` + const repoData = response[`repo${index}`] + const pullRequests = new Map() + + if (repoData?.pullRequests?.edges) { + type RawGraphQLPullRequest = { + number: number + headRefName: string + baseRefName: string + baseRefOid: string + files?: { + nodes?: { path: string }[] + } + } + const pullRequestEdges = repoData.pullRequests.edges as Edge[] + + pullRequestEdges.forEach(edge => { + const pr = edge.node + const changedFiles = pr.files?.nodes?.map(f => f.path) || [] + pullRequests.set(pr.headRefName, { + number: pr.number, + headRefName: pr.headRefName, + baseRefName: pr.baseRefName, + baseRefOid: pr.baseRefOid, + changedFiles + }) + }) + } + + allPullRequests.set(repoKey, pullRequests) + }) + + return allPullRequests + } + private async getRepositoriesForSearchQuery(params: { searchQuery: string, cursor?: string diff --git a/src/features/projects/domain/IGitHubRepositoryDataSource.ts b/src/features/projects/domain/IGitHubRepositoryDataSource.ts index b48ff43e..c9c86e4e 100644 --- a/src/features/projects/domain/IGitHubRepositoryDataSource.ts +++ b/src/features/projects/domain/IGitHubRepositoryDataSource.ts @@ -17,14 +17,14 @@ export type GitHubRepository = { export type GitHubRepositoryRef = { readonly id: string - readonly name: string + readonly name: string + readonly baseRef?: string + readonly baseRefOid?: string + readonly prNumber?: number readonly files: { readonly name: string }[] -} - -export default interface IGitHubRepositoryDataSource { - getRepositories(): Promise + readonly changedFiles?: string[] } export default interface IGitHubRepositoryDataSource { diff --git a/src/features/projects/domain/OpenApiSpecification.ts b/src/features/projects/domain/OpenApiSpecification.ts index b0c3bfa5..649d5324 100644 --- a/src/features/projects/domain/OpenApiSpecification.ts +++ b/src/features/projects/domain/OpenApiSpecification.ts @@ -4,7 +4,12 @@ export const OpenApiSpecificationSchema = z.object({ id: z.string(), name: z.string(), url: z.string(), + urlHash: z.string().optional(), editURL: z.string().optional(), + diffURL: z.string().optional(), + diffBaseBranch: z.string().optional(), + diffBaseOid: z.string().optional(), + diffPrUrl: z.string().optional(), isDefault: z.boolean() }) diff --git a/src/features/projects/domain/ProjectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts index e3d03217..0adcd1e7 100644 --- a/src/features/projects/domain/ProjectNavigator.ts +++ b/src/features/projects/domain/ProjectNavigator.ts @@ -35,10 +35,12 @@ export default class ProjectNavigator { return e.name == preferredSpecificationName }) if (candidateSpecification) { - this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${candidateSpecification.id}`) + const encodedPath = this.encodePath(project.owner, project.name, newVersion.id, candidateSpecification.id) + this.router.push(encodedPath) } else { const defaultOrFirstSpecification = getDefaultSpecification(newVersion) - this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${defaultOrFirstSpecification.id}`) + const encodedPath = this.encodePath(project.owner, project.name, newVersion.id, defaultOrFirstSpecification.id) + this.router.push(encodedPath) } } @@ -48,9 +50,10 @@ export default class ProjectNavigator { versionId: string, specificationId: string ) { - this.router.push(`/${projectOwner}/${projectName}/${versionId}/${specificationId}`) + const encodedPath = this.encodePath(projectOwner, projectName, versionId, specificationId) + this.router.push(encodedPath) } - + navigateIfNeeded(selection: { projectOwner?: string projectName?: string @@ -60,9 +63,15 @@ export default class ProjectNavigator { if (!selection.projectOwner || !selection.projectName || !selection.versionId || !selection.specificationId) { return } - const path = `/${selection.projectOwner}/${selection.projectName}/${selection.versionId}/${selection.specificationId}` + const path = this.encodePath(selection.projectOwner, selection.projectName, selection.versionId, selection.specificationId) if (path !== this.pathnameReader.pathname) { this.router.replace(path) } } + + private encodePath(owner: string, projectName: string, versionId: string, specificationId: string): string { + const encodedVersionId = versionId.split('/').map(segment => encodeURIComponent(segment)).join('/') + const encodedSpecificationId = encodeURIComponent(specificationId) + return `/${owner}/${projectName}/${encodedVersionId}/${encodedSpecificationId}` + } } diff --git a/src/features/projects/domain/Version.ts b/src/features/projects/domain/Version.ts index f6b69989..48dbc4d0 100644 --- a/src/features/projects/domain/Version.ts +++ b/src/features/projects/domain/Version.ts @@ -6,7 +6,7 @@ export const VersionSchema = z.object({ name: z.string(), specifications: OpenApiSpecificationSchema.array(), url: z.string().optional(), - isDefault: z.boolean().default(false) + isDefault: z.boolean().default(false), }) type Version = z.infer diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx index e680ad57..2d60c844 100644 --- a/src/features/projects/view/ProjectsContextProvider.tsx +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -15,10 +15,9 @@ const ProjectsContextProvider = ({ const [refreshing, setRefreshing] = useState(false); const isLoadingRef = useRef(false); - - const setProjectsAndRefreshed = (value: Project[]) => { - setProjects(value); - }; + // Fingerprint uses urlHash for remote specs (stable), URL for others (already stable) + const fingerprint = (list: Project[]) => + list.flatMap(p => p.versions.flatMap(v => v.specifications.map(s => s.urlHash ?? s.url))).sort().join(); const refreshProjects = useCallback(() => { if (isLoadingRef.current) return; @@ -26,8 +25,10 @@ const refreshProjects = useCallback(() => { setRefreshing(true); fetch("/api/refresh-projects", { method: "POST" }) .then((res) => res.json()) - .then(({ projects }) => { - if (projects) setProjectsAndRefreshed(projects); + .then(({ projects: newProjects }) => { + if (newProjects) { + setProjects(prev => fingerprint(prev) === fingerprint(newProjects) ? prev : newProjects); + } }) .catch((error) => console.error("Failed to refresh projects", error)) .finally(() => { @@ -47,8 +48,10 @@ useEffect(() => { if (!document.hidden) refreshProjects(); }; document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", refreshProjects); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", refreshProjects); window.clearTimeout(timeout); }; }, [refreshProjects]); diff --git a/src/features/projects/view/toolbar/MobileToolbar.tsx b/src/features/projects/view/toolbar/MobileToolbar.tsx index 730ad7c7..821af89a 100644 --- a/src/features/projects/view/toolbar/MobileToolbar.tsx +++ b/src/features/projects/view/toolbar/MobileToolbar.tsx @@ -28,7 +28,11 @@ const MobileToolbar = () => { sx={{ width: "100%" }} /> ({ + id: spec.id, + name: spec.name, + hasChanges: !!spec.diffURL + }))} selection={specification.id} onSelect={selectSpecification} sx={{ width: "100%" }} diff --git a/src/features/projects/view/toolbar/Selector.tsx b/src/features/projects/view/toolbar/Selector.tsx index 4607d1f0..91261998 100644 --- a/src/features/projects/view/toolbar/Selector.tsx +++ b/src/features/projects/view/toolbar/Selector.tsx @@ -4,13 +4,16 @@ import { MenuItem, SelectChangeEvent, FormControl, - Typography + Typography, + Box, + Tooltip } from "@mui/material" import MenuItemHover from "@/common/ui/MenuItemHover" interface SelectorItem { readonly id: string readonly name: string + readonly hasChanges?: boolean } const Selector = ({ @@ -45,13 +48,29 @@ const Selector = ({ {items.map(item => ( - - {item.name} - + + + {item.name} + + {item.hasChanges && ( + + + + )} + ))} diff --git a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx index 4d74f06a..ad3523aa 100644 --- a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx +++ b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx @@ -59,7 +59,11 @@ const TrailingToolbarItem = () => { /> / ({ + id: spec.id, + name: spec.name, + hasChanges: !!spec.diffURL + }))} selection={specification.id} onSelect={selectSpecification} sx={{ marginRight: 0.5 }} @@ -68,7 +72,7 @@ const TrailingToolbarItem = () => { } {specification.editURL && - + { } + ) diff --git a/src/features/sidebar/data/index.ts b/src/features/sidebar/data/index.ts index d151c029..c5c8a71b 100644 --- a/src/features/sidebar/data/index.ts +++ b/src/features/sidebar/data/index.ts @@ -1,2 +1,3 @@ export { default as useCloseSidebarOnSelection } from "./useCloseSidebarOnSelection" +export { default as useDiff } from "./useDiff" export { default as useSidebarOpen } from "./useSidebarOpen" diff --git a/src/features/sidebar/data/useDiff.ts b/src/features/sidebar/data/useDiff.ts new file mode 100644 index 00000000..49bc38ee --- /dev/null +++ b/src/features/sidebar/data/useDiff.ts @@ -0,0 +1,83 @@ +import { useState, useEffect } from "react" +import { useProjectSelection } from "@/features/projects/data" +import { DiffChange } from "../../diff/domain/DiffChange" + +interface DiffData { + changes: DiffChange[] + error?: string | null + isNewFile?: boolean +} + +export default function useDiff() { + const { specification } = useProjectSelection() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const diffUrl = specification?.diffURL + + useEffect(() => { + if (!diffUrl) { + return + } + + let isCancelled = false + + const fetchDiff = async () => { + if (isCancelled) { + return + } + + setLoading(true) + setError(null) + setData(null) + + try { + const res = await fetch(diffUrl) + const result = await res.json() + + if (isCancelled) { + return + } + + if (result.error) { + setData(null) + setError(result.error) + } else { + setData(result) + setError(null) + } + setLoading(false) + } catch (err) { + if (isCancelled) { + return + } + + console.error("Failed to fetch diff:", err) + setData(null) + setError("We couldn't load the diff right now. Please try again later.") + setLoading(false) + } + } + + fetchDiff() + + return () => { + isCancelled = true + } + }, [diffUrl]) + + const hasDiffUrl = Boolean(diffUrl) + const resolvedData = hasDiffUrl ? data : { changes: [] } + const resolvedChanges = resolvedData?.changes ?? [] + const resolvedLoading = hasDiffUrl ? loading : false + const resolvedError = hasDiffUrl ? error : null + const isNewFile = resolvedData?.isNewFile ?? false + + return { + data: resolvedData, + loading: resolvedLoading, + changes: resolvedChanges, + error: resolvedError, + isNewFile + } +} diff --git a/src/features/sidebar/data/useDiffbarOpen.ts b/src/features/sidebar/data/useDiffbarOpen.ts new file mode 100644 index 00000000..964efd8c --- /dev/null +++ b/src/features/sidebar/data/useDiffbarOpen.ts @@ -0,0 +1,5 @@ +import { useSessionStorage } from "usehooks-ts" + +export default function useDiffbarOpen() { + return useSessionStorage("isDiffbarOpen", false, { initializeWithValue: false }) +} \ No newline at end of file diff --git a/src/features/sidebar/data/useSidebarOpen.ts b/src/features/sidebar/data/useSidebarOpen.ts index 3d4c1be7..1a8dc151 100644 --- a/src/features/sidebar/data/useSidebarOpen.ts +++ b/src/features/sidebar/data/useSidebarOpen.ts @@ -1,5 +1,5 @@ import { useSessionStorage } from "usehooks-ts" export default function useSidebarOpen() { - return useSessionStorage("isSidebarOpen", true) + return useSessionStorage("isSidebarOpen", true, { initializeWithValue: false }) } \ No newline at end of file diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index 95599c3e..870bae8b 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -2,12 +2,16 @@ import { useState, useEffect, useContext } from "react" import { useSessionStorage } from "usehooks-ts" -import { Box, IconButton, Stack, Tooltip, Collapse } from "@mui/material" +import { Box, IconButton, Stack, Tooltip, Collapse, Divider } from "@mui/material" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons" +import { faBars, faChevronLeft, faChevronRight, faArrowRightArrowLeft } from "@fortawesome/free-solid-svg-icons" import { isMac as checkIsMac, SidebarTogglableContext } from "@/common" import { useSidebarOpen } from "@/features/sidebar/data" +import useDiffbarOpen from "@/features/sidebar/data/useDiffbarOpen" import ToggleMobileToolbarButton from "./internal/secondary/ToggleMobileToolbarButton" +import { useProjectSelection } from "@/features/projects/data" + +const isDiffFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR === "true" const SecondarySplitHeader = ({ mobileToolbar, @@ -17,7 +21,9 @@ const SecondarySplitHeader = ({ children?: React.ReactNode }) => { const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + const [isDiffbarOpen, setDiffbarOpen] = useDiffbarOpen() const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) + const { specification } = useProjectSelection() return ( - - + + {children} - {mobileToolbar && + + {mobileToolbar && ( setMobileToolbarVisible(!isMobileToolbarVisible) } /> - } + )} + {isDiffFeatureEnabled && ( + + )} - {mobileToolbar && + {mobileToolbar && ( - } + )} ) } @@ -78,7 +92,7 @@ const ToggleSidebarButton = ({ }, [setIsMac]) const isSidebarTogglable = useContext(SidebarTogglableContext) const openCloseKeyboardShortcut = `(${isMac ? "⌘" : "^"} + .)` - const tooltip = isSidebarOpen ? "Show Projects" : "Hide Projects" + const tooltip = isSidebarOpen ? "Hide Projects" : "Show Projects" return ( @@ -98,3 +112,52 @@ const ToggleSidebarButton = ({ ) } + +const ToggleDiffButton = ({ + isDiffbarOpen, + onClick, + isDiffAvailable +}: { + isDiffbarOpen: boolean, + onClick: (isDiffbarOpen: boolean) => void, + isDiffAvailable: boolean +}) => { + const [isMac, setIsMac] = useState(false) + useEffect(() => { + // checkIsMac uses window so we delay the check. + const timeout = window.setTimeout(() => { + setIsMac(checkIsMac()) + }, 0) + return () => window.clearTimeout(timeout) + }, []) + const isSidebarTogglable = useContext(SidebarTogglableContext) + const openCloseKeyboardShortcut = `(${isMac ? "⌘" : "^"} + K)` + const isDisabled = !isDiffAvailable && !isDiffbarOpen + const tooltip = isDisabled + ? "Changes cannot be displayed" + : isDiffbarOpen + ? "Hide changes" + : "Show changes" + return ( + + + + + onClick(!isDiffbarOpen)} + edge="end" + disabled={isDisabled} + > + + + + + + ) +} diff --git a/src/features/sidebar/view/SplitView.tsx b/src/features/sidebar/view/SplitView.tsx index 04bd4eb9..c6d100c0 100644 --- a/src/features/sidebar/view/SplitView.tsx +++ b/src/features/sidebar/view/SplitView.tsx @@ -1,6 +1,7 @@ import ClientSplitView from "./internal/ClientSplitView" import BaseSidebar from "./internal/sidebar/Sidebar" import ProjectList from "./internal/sidebar/projects/ProjectList" +import DiffContent from "./internal/diffbar/DiffContent" import { env } from "@/common" const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") @@ -8,7 +9,7 @@ const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL") const SplitView = ({ children }: { children?: React.ReactNode }) => { return ( - }> + } sidebarRight={}> {children} ) diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx index 38c6735f..dbda81e6 100644 --- a/src/features/sidebar/view/internal/ClientSplitView.tsx +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -4,17 +4,26 @@ import { useEffect, useContext } from "react" import { Stack, useMediaQuery, useTheme } from "@mui/material" import { isMac, useKeyboardShortcut, SidebarTogglableContext } from "@/common" import { useSidebarOpen } from "../../data" +import useDiffbarOpen from "../../data/useDiffbarOpen" +import { useProjectSelection } from "@/features/projects/data" import PrimaryContainer from "./primary/Container" import SecondaryContainer from "./secondary/Container" +import RightContainer from "./tertiary/RightContainer" + +const isDiffFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_DIFF_SIDEBAR === "true" const ClientSplitView = ({ sidebar, - children + children, + sidebarRight }: { sidebar: React.ReactNode children?: React.ReactNode + sidebarRight?: React.ReactNode }) => { const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + const [isRightSidebarOpen, setRightSidebarOpen] = useDiffbarOpen() + const { specification } = useProjectSelection() const isSidebarTogglable = useContext(SidebarTogglableContext) const theme = useTheme() // Determine if the screen size is small or larger @@ -25,6 +34,13 @@ const ClientSplitView = ({ setSidebarOpen(true) } }, [isSidebarOpen, isSidebarTogglable, setSidebarOpen]) + + // Close diff sidebar if no specification is selected + useEffect(() => { + if (!specification && isRightSidebarOpen) { + setRightSidebarOpen(false) + } + }, [specification, isRightSidebarOpen, setRightSidebarOpen]) useKeyboardShortcut(event => { const isActionKey = isMac() ? event.metaKey : event.ctrlKey if (isActionKey && event.key === ".") { @@ -34,7 +50,17 @@ const ClientSplitView = ({ } } }, [isSidebarTogglable, setSidebarOpen]) + + useKeyboardShortcut(event => { + const isActionKey = isMac() ? event.metaKey : event.ctrlKey + if (isDiffFeatureEnabled && isActionKey && event.key === "k") { + event.preventDefault() + setRightSidebarOpen(!isRightSidebarOpen) + } + }, [isRightSidebarOpen, setRightSidebarOpen]) + const sidebarWidth = 320 + const diffWidth = 320 return ( @@ -45,9 +71,16 @@ const ClientSplitView = ({ > {sidebar} - + {children} + setRightSidebarOpen(false)} + > + {sidebarRight} + ) } diff --git a/src/features/sidebar/view/internal/diffbar/DiffContent.tsx b/src/features/sidebar/view/internal/diffbar/DiffContent.tsx new file mode 100644 index 00000000..b0f1aa24 --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/DiffContent.tsx @@ -0,0 +1,115 @@ +"use client" + +import { Alert, Box, Typography, Link } from "@mui/material" +import useDiff from "@/features/sidebar/data/useDiff" +import { useProjectSelection } from "@/features/projects/data" +import useDocumentationVisualizer from "@/features/settings/data/useDocumentationVisualizer" +import { scrollToOperation } from "@/features/docs/navigation" +import DiffList, { DiffListStatus } from "./components/DiffList" +import { DiffChange } from "@/features/diff/domain/DiffChange" + +const DiffContent = () => { + const { data, loading, changes, error, isNewFile } = useDiff() + const { specification } = useProjectSelection() + const [visualizer] = useDocumentationVisualizer() + + const handleChangeClick = (change: DiffChange) => { + scrollToOperation(visualizer, change.operationId, change.operation, change.path) + } + + const hasData = Boolean(data) + const hasChanges = changes.length > 0 + const diffStatus: DiffListStatus = loading + ? "loading" + : error + ? "error" + : isNewFile + ? "empty" + : hasData && hasChanges + ? "ready" + : hasData + ? "empty" + : "idle" + + return ( + + + What has changed? + + + {specification?.diffBaseBranch && specification?.diffBaseOid && ( + + Comparing to:{" "} + {specification.diffPrUrl ? ( + + {specification.diffBaseBranch} ({specification.diffBaseOid.substring(0, 7)}) + + ) : ( + `${specification.diffBaseBranch} (${specification.diffBaseOid.substring(0, 7)})` + )} + + )} + + {isNewFile && ( + + + This is a new file that doesn't exist on the base branch. + + + )} + + {error ? ( + + {error} + + ) : null} + + {!isNewFile && ( + + + + )} + + ) +} + +export default DiffContent diff --git a/src/features/sidebar/view/internal/diffbar/components/DiffList.tsx b/src/features/sidebar/view/internal/diffbar/components/DiffList.tsx new file mode 100644 index 00000000..0a0606e2 --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/components/DiffList.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Box, Typography } from "@mui/material" +import PopulatedDiffList from "./PopulatedDiffList" +import { DiffChange } from "@/features/diff/domain/DiffChange" + +export type DiffListStatus = "idle" | "loading" | "empty" | "ready" | "error" + +const DiffList = ({ + changes, + status, + onClick, +}: { + changes: DiffChange[] + status: DiffListStatus + onClick: (change: DiffChange) => void +}) => { + if (status === "loading") { + return ( + + + Loading changes... + + + ) + } else if (status === "empty") { + return ( + + + No changes + + + ) + } else if (status === "ready") { + return ( + + ) + } + + return null +} + +export default DiffList diff --git a/src/features/sidebar/view/internal/diffbar/components/DiffListItem.tsx b/src/features/sidebar/view/internal/diffbar/components/DiffListItem.tsx new file mode 100644 index 00000000..fd88e698 --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/components/DiffListItem.tsx @@ -0,0 +1,103 @@ +"use client" + +import { Box, Typography, ListItem, ListItemButton, Stack } from "@mui/material" +import MenuItemHover from "@/common/ui/MenuItemHover" +import MonoQuotedText from "./MonoQuotedText" +import { getLevelConfig, Level } from "./levelConfig" + +const DiffListItem = ({ + path, + text, + level, + operation, + onClick, +}: { + path?: string + text?: string + level?: number + operation?: string + onClick: () => void +}) => { + const levelConfig = getLevelConfig((level ?? 1) as Level) + + return ( + + + + + + + {levelConfig.label} + + {operation && path && ( + + at{" "} + + {operation} + {" "} + + {path} + + + )} + {!operation && path && ( + + {path} + + )} + {text && ( + + + + )} + + + + + + ) +} + +export default DiffListItem diff --git a/src/features/sidebar/view/internal/diffbar/components/MonoQuotedText.tsx b/src/features/sidebar/view/internal/diffbar/components/MonoQuotedText.tsx new file mode 100644 index 00000000..3bf0fb41 --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/components/MonoQuotedText.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Box } from "@mui/material" + +const MonoQuotedText = ({ text }: { text: string }) => { + return ( + <> + {text.split(/(['`])([^'`]+)\1/g).map((part, i) => + i % 3 === 2 ? ( + + {part} + + ) : i % 3 === 1 ? null : ( + part + ) + )} + + ) +} + +export default MonoQuotedText diff --git a/src/features/sidebar/view/internal/diffbar/components/PopulatedDiffList.tsx b/src/features/sidebar/view/internal/diffbar/components/PopulatedDiffList.tsx new file mode 100644 index 00000000..3ae25f48 --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/components/PopulatedDiffList.tsx @@ -0,0 +1,36 @@ +"use client" + +import SpacedList from "@/common/ui/SpacedList" +import DiffListItem from "./DiffListItem" +import { DiffChange } from "@/features/diff/domain/DiffChange" + +const PopulatedDiffList = ({ + changes, + onClick, +}: { + changes: DiffChange[] + onClick: (change: DiffChange) => void +}) => { + return ( + + {changes.map((change) => ( + onClick(change)} + /> + ))} + + ) +} + +export default PopulatedDiffList diff --git a/src/features/sidebar/view/internal/diffbar/components/levelConfig.ts b/src/features/sidebar/view/internal/diffbar/components/levelConfig.ts new file mode 100644 index 00000000..afe0581c --- /dev/null +++ b/src/features/sidebar/view/internal/diffbar/components/levelConfig.ts @@ -0,0 +1,13 @@ +export type Level = 1 | 2 | 3 + +export const getLevelConfig = (level: Level) => { + switch (level) { + case 3: + return { label: "breaking", color: "#ff5555" } + case 2: + return { label: "warn", color: "#ffaa33" } + case 1: + default: + return { label: "info", color: "#44ddee" } + } +} diff --git a/src/features/sidebar/view/internal/secondary/Container.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx index 29f2b213..6a3a9ead 100644 --- a/src/features/sidebar/view/internal/secondary/Container.tsx +++ b/src/features/sidebar/view/internal/secondary/Container.tsx @@ -6,11 +6,15 @@ import CustomTopLoader from "@/common/ui/CustomTopLoader" const SecondaryContainer = ({ sidebarWidth, offsetContent, + diffWidth, + offsetDiffContent, children, isSM, }: { sidebarWidth: number offsetContent: boolean + diffWidth?: number + offsetDiffContent?: boolean children?: React.ReactNode, isSM: boolean, }) => { @@ -21,6 +25,8 @@ const SecondaryContainer = ({ sidebarWidth={isSM ? sidebarWidth : 0} isSidebarOpen={isSM ? offsetContent: false} + diffWidth={isSM ? (diffWidth || 0) : 0} + isDiffOpen={isSM ? (offsetDiffContent || false) : false} sx={{ ...sx }} > {children} @@ -35,33 +41,41 @@ export default SecondaryContainer interface WrapperStackProps { readonly sidebarWidth: number readonly isSidebarOpen: boolean + readonly diffWidth: number + readonly isDiffOpen: boolean } const WrapperStack = styled(Stack, { - shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" -})(({ theme, sidebarWidth, isSidebarOpen }) => ({ - transition: theme.transitions.create("margin", { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen - }), - marginLeft: `-${sidebarWidth}px`, - ...(isSidebarOpen && { - transition: theme.transitions.create("margin", { - easing: theme.transitions.easing.easeOut, - duration: theme.transitions.duration.enteringScreen, + shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth" && prop !== "diffWidth" && prop !== "isDiffOpen" +})(({ theme, sidebarWidth, isSidebarOpen, diffWidth, isDiffOpen }) => { + return { + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen }), - marginLeft: 0 - }) -})) + marginLeft: isSidebarOpen ? 0 : `-${sidebarWidth}px`, + marginRight: isDiffOpen ? 0 : `-${diffWidth}px`, + ...((isSidebarOpen || isDiffOpen) && { + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }), + }; +}) const InnerSecondaryContainer = ({ sidebarWidth, isSidebarOpen, + diffWidth, + isDiffOpen, children, sx }: { sidebarWidth: number isSidebarOpen: boolean + diffWidth: number + isDiffOpen: boolean children: React.ReactNode sx?: SxProps }) => { @@ -71,6 +85,8 @@ const InnerSecondaryContainer = ({ spacing={0} sidebarWidth={sidebarWidth} isSidebarOpen={isSidebarOpen} + diffWidth={diffWidth} + isDiffOpen={isDiffOpen} sx={{ ...sx, width: "100%", overflowY: "auto" }} > diff --git a/src/features/sidebar/view/internal/sidebar/Header.tsx b/src/features/sidebar/view/internal/sidebar/Header.tsx index 3c17d48c..3b6a2258 100644 --- a/src/features/sidebar/view/internal/sidebar/Header.tsx +++ b/src/features/sidebar/view/internal/sidebar/Header.tsx @@ -1,14 +1,48 @@ "use client" +import { useContext, useEffect, useRef, useState } from "react" import Image from "next/image" -import { Box, Button, Typography } from "@mui/material" +import { Box, Button, CircularProgress, Tooltip, Typography } from "@mui/material" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faCheck } from "@fortawesome/free-solid-svg-icons" import { useRouter } from "next/navigation" import * as NProgress from "nprogress" +import { ProjectsContext } from "@/common" import { useCloseSidebarOnSelection } from "@/features/sidebar/data" const Header = ({ siteName }: { siteName?: string }) => { const router = useRouter() const { closeSidebarIfNeeded } = useCloseSidebarOnSelection() + const { refreshing } = useContext(ProjectsContext) + const [showCheck, setShowCheck] = useState(false) + const [fadeOut, setFadeOut] = useState(false) + const wasRefreshing = useRef(false) + + useEffect(() => { + if (refreshing) { + // Clear any existing checkmark when a new refresh starts + const clearTimeout_ = setTimeout(() => { + setShowCheck(false) + setFadeOut(false) + }, 0) + wasRefreshing.current = true + return () => clearTimeout(clearTimeout_) + } else if (wasRefreshing.current) { + wasRefreshing.current = false + // Delay checkmark appearance to let spinner fade out first + const showTimeout = setTimeout(() => { + setShowCheck(true) + setFadeOut(false) + }, 400) + const fadeTimeout = setTimeout(() => setFadeOut(true), 1600) + const hideTimeout = setTimeout(() => setShowCheck(false), 2200) + return () => { + clearTimeout(showTimeout) + clearTimeout(fadeTimeout) + clearTimeout(hideTimeout) + } + } + }, [refreshing]) return ( { {siteName} + + + + + + + + + + ) } diff --git a/src/features/sidebar/view/internal/tertiary/RightContainer.tsx b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx new file mode 100644 index 00000000..f48f7adb --- /dev/null +++ b/src/features/sidebar/view/internal/tertiary/RightContainer.tsx @@ -0,0 +1,87 @@ +'use client' + +import { SxProps } from "@mui/system" +import { Drawer as MuiDrawer } from "@mui/material" +import { useTheme } from "@mui/material/styles" + +const RightContainer = ({ + width, + isOpen, + onClose, + children +}: { + width: number + isOpen: boolean + onClose?: () => void + children?: React.ReactNode +}) => { + return ( + <> + + {children} + + + {children} + + + ) +} + +export default RightContainer + +const InnerRightContainer = ({ + variant, + width, + isOpen, + onClose, + keepMounted, + sx, + children +}: { + variant: "persistent" | "temporary" + width: number + isOpen: boolean + onClose?: () => void + keepMounted?: boolean + sx: SxProps + children?: React.ReactNode +}) => { + const theme = useTheme() + return ( + + {children} + + ) +} \ No newline at end of file