diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index a26ad8491dfa0..0000000000000 --- a/.browserslistrc +++ /dev/null @@ -1,13 +0,0 @@ -# https://github.com/browserslist/browserslist#readme - -defaults and supports es6-module -maintained node versions - -[production] - -cover 95% -not dead - -[development] - -defaults diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000000000..f53ec0ae2900b --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,11 @@ +# Changesets + +Changesets in this directory describe user-facing changes to published packages. Add one by running: + +```shell +pnpm changeset +``` + +Commit the generated Markdown file with the package change. The publishing workflow consumes these files to update package versions and changelogs before publishing to npm. + +See the [package publishing guide](../docs/package-publishing.md) for the complete release process. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000000000..19d462977d6da --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "nodejs/nodejs.org" } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.changeset/import-aliases-no-preconditions.md b/.changeset/import-aliases-no-preconditions.md new file mode 100644 index 0000000000000..252399d58c0f9 --- /dev/null +++ b/.changeset/import-aliases-no-preconditions.md @@ -0,0 +1,8 @@ +--- +'@node-core/ui-components': patch +--- + +Resolve the `#ui/*` import alias to the compiled output by default, so consumers +of the published package no longer need to opt into a bundler-specific +`rolldown` resolution condition. The uncompiled sources stay reachable through +the new `source` condition, which this repository's own tooling opts into. diff --git a/.changeset/shiki-more-more.md b/.changeset/shiki-more-more.md new file mode 100644 index 0000000000000..9f206d3fe804a --- /dev/null +++ b/.changeset/shiki-more-more.md @@ -0,0 +1,5 @@ +--- +'@node-core/rehype-shiki': minor +--- + +Add support for all Shiki languages when the `minimal` preset is not selected. diff --git a/.cz.json b/.cz.json deleted file mode 100644 index 3cadd75a82b80..0000000000000 --- a/.cz.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "path": "./node_modules/cz-conventional-changelog", - "disableSubjectLowerCase": true -} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ab22ce98c29c5..0000000000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -# Node.js -node_modules - -# Next.js & Vercel Directories -.turbo diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000..158da6920f6c7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,50 @@ +# Website Development +* @nodejs/nodejs-website + +# Infrastructure +.github @nodejs/web-infra +.husky @nodejs/web-infra +.nvmrc @nodejs/web-infra +codecov.yml @nodejs/web-infra +packages/ui-components/scripts/publish.mjs @nodejs/web-infra + +# Dependencies +pnpm-workspace.yaml @nodejs/nodejs-website @nodejs/web-infra +pnpm-lock.yaml @nodejs/web-infra + +# Framework +apps/site/next.config.mjs @nodejs/web-infra +apps/site/next.dynamic.mjs @nodejs/web-infra +apps/site/middleware.ts @nodejs/web-infra +apps/site/navigation.mjs @nodejs/web-infra +apps/site/playwright.config.mjs @nodejs/web-infra + +# Package Ecosystem +package.json @nodejs/nodejs-website +turbo.json @nodejs/nodejs-website @nodejs/web-infra + +# Web Infrastructure +crowdin.yml @nodejs/web-infra +apps/site/redirects.json @nodejs/web-infra +apps/site/site.json @nodejs/web-infra +platforms @nodejs/web-infra + +# Critical Documents +LICENSE @nodejs/tsc +CONTRIBUTING.md @nodejs/nodejs-website @nodejs/web-infra +docs @nodejs/nodejs-website @nodejs/web-infra +SECURITY.md @nodejs/security-wg +apps/site/public/.well-known/security.txt @nodejs/security-wg + +# Node.js Release Blog Posts +apps/site/pages/en/blog/release @nodejs/releasers +apps/site/pages/en/blog/announcements @nodejs/releasers +apps/site/pages/en/blog/vulnerability @nodejs/releasers + +# Marketing +apps/site/pages/en/about/partners.mdx @nodejs/marketing +apps/site/pages/en/about/branding.mdx @nodejs/marketing + +# The following users DO NOT have write access, and their review is requested +# via a GitHub action. +apps/site/pages/en/blog/migrations @nodejs/userland-migrations diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 922db7330cb2b..ce6f540b49da4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: true contact_links: + - name: Node.js Status Page + url: https://status.nodejs.org + about: 'Need to check if there is any ongoing incidents?' - name: Report an API Docs Issue on the Node.js Website url: https://github.com/nodejs/node/issues/new?assignees=&labels=doc&template=3-api-ref-docs-problem.yml about: 'Is something wrong with the API Docs? Did you face a bug with the API Docs?' diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 053bfad342f18..fc641b4b78268 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,7 +26,7 @@ You can check the items by adding an `x` between the brackets, like this: `[x]` --> - [ ] I have read the [Contributing Guidelines](https://github.com/nodejs/nodejs.org/blob/main/CONTRIBUTING.md) and made commit messages that follow the guideline. -- [ ] I have run `npm run format` to ensure the code follows the style guide. -- [ ] I have run `npm run test` to check if all tests are passing. -- [ ] I have run `npx turbo build` to check if the website builds without errors. +- [ ] I have run `pnpm format` to ensure the code follows the style guide. +- [ ] I have run `pnpm test` to check if all tests are passing. +- [ ] I have run `pnpm build` to check if the website builds without errors. - [ ] I've covered new added functionality with unit tests if necessary. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e9a555083b2da..bb9dc3da2e04c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,68 +5,102 @@ updates: schedule: interval: monthly labels: + - 'dependencies' + - 'github_actions' - 'github_actions:pull-request' + - 'auto-merge' commit-message: - prefix: meta + prefix: chore(deps) + cooldown: + default-days: 7 open-pull-requests-limit: 10 + - package-ecosystem: npm directory: '/' versioning-strategy: increase schedule: interval: monthly labels: + - 'dependencies' + - 'javascript' - 'github_actions:pull-request' + - 'auto-merge' commit-message: - prefix: meta + prefix: chore(deps) + cooldown: + default-days: 7 groups: lint: patterns: + - '@eslint/*' - '@typescript-eslint/*' - - 'eslint' - - 'eslint-*' - - 'stylelint' - - 'stylelint-*' + - acorn + - eslint + - eslint-* + - lint-staged + - prettier + - prettier-* + - stylelint + - stylelint-* + - typescript-eslint + - unified exclude-patterns: - 'eslint-plugin-storybook' - storybook: - patterns: - - 'storybook' - - '@storybook/*' - - 'eslint-plugin-storybook' - testing: - patterns: - - '@testing-library/*' - - '@types/testing-library*' - - '@types/jest' - - 'jest' - - 'jest-*' - next-js: - patterns: - - 'next' - - 'turbo' - - 'next-mdx-remote' - - 'next-sitemap' - - 'next-themes' - - '@vercel/*' + - 'prettier-plugin-tailwindcss' mdx: patterns: - '@vcarl/remark-headings' + - '@shikijs/*' - '@mdx-js/*' - - 'rehype-*' - - 'remark-*' + - hast-util-* + - rehype-* + - remark-* + - shiki + - sval + - unist-util-* + - vfile + - vfile-* + - reading-time + - twoslash + orama: + patterns: + - '@orama/*' + - '@oramacloud/*' + radix: + patterns: + - '@radix-ui/*' react: patterns: - 'react' - 'react-dom' - '@types/react' - - '@types/react-dom' - tailwind: + storybook: + patterns: + - 'storybook' + - '@storybook/*' + - 'eslint-plugin-storybook' + styling: patterns: - '@savvywombat/tailwindcss-grid-areas' + - '@tailwindcss/*' - 'prettier-plugin-tailwindcss' - 'tailwindcss' + testing: + patterns: + - '@testing-library/*' + - '@reporters/*' + - global-jsdom + - jsdom + - tsx + vercel: + patterns: + - '@next/*' + - '@opentelemetry/*' + - '@vercel/*' + - next + - next-* + - turbo ignore: - # Manually update major versions of @types/node with the version specified within .nvmrc - dependency-name: '@types/node' update-types: ['version-update:semver-major'] open-pull-requests-limit: 10 diff --git a/.github/scorecard.yml b/.github/scorecard.yml new file mode 100644 index 0000000000000..e461abd489a61 --- /dev/null +++ b/.github/scorecard.yml @@ -0,0 +1,8 @@ +# annotations tell scorecard that we have mitigated a concern. automation is only so good at establishing context +# https://github.com/ossf/scorecard/blob/main/config/README.md#annotating-your-project +annotations: + # our workflows only run when a maintainer allows it + - checks: + - dangerous-workflow + reasons: + - reason: remediated diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000000000..2fa6e29c31ab4 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,33 @@ +name: Auto-merge PRs + +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + auto-merge: + name: Auto-merge + if: github.repository == 'nodejs/nodejs.org' + runs-on: ubuntu-latest + permissions: + # Required to approve and merge pull requests + pull-requests: write + # Required to merge pull requests via merge queue + contents: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - uses: nodejs/web-team/actions/auto-merge-prs@b087df186d25f8792fb85cc7794f68718726b8ee + with: + merge-method: queue diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5d634b5399f3..d822f64cf115a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,11 +10,10 @@ on: push: branches: - main - pull_request_target: + pull_request: branches: - main - types: - - labeled + types: [opened, synchronize, reopened, ready_for_review] merge_group: defaults: @@ -26,16 +25,12 @@ permissions: contents: read actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: - # This Job should run either on `merge_groups` or `push` events - # or `pull_request_target` event with a `labeled` action with a label named `github_actions:pull-request` - # since we want to run Website Builds on all these 3 occasions. As this allows us to be certain the that builds are passing - if: | - (github.event_name == 'push' || github.event_name == 'merge_group') || - (github.event_name == 'pull_request_target' && - github.event.label.name == 'github_actions:pull-request') - name: Build on ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -45,18 +40,10 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f with: - egress-policy: audit - - - name: Provide Turborepo Arguments - # This step is responsible for providing a reusable string that can be used within other steps and jobs - # that use the `turbo` cli command as a way of easily providing shared arguments to the `turbo` command - id: turborepo_arguments - # See https://turbo.build/repo/docs/reference/command-line-reference/run#--cache-dir - # See https://turbo.build/repo/docs/reference/command-line-reference/run#--force - run: echo "turbo_args=--force=true --cache-dir=.turbo/cache" >> "$GITHUB_OUTPUT" + pnpm: true + use-version-file: true - name: Use GNU tar instead BSD tar # This ensures that we use GNU `tar` which is more efficient for extracting caches's @@ -64,72 +51,107 @@ jobs: shell: cmd run: echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%" - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - # Provides the Pull Request commit SHA or the GitHub merge group ref - ref: ${{ github.event.pull_request.head.sha || github.ref }} - # We only need to fetch the last commit from the head_ref - # since we're not using the `--filter` operation from turborepo - # We don't use the `--filter` as we always want to force builds regardless of having changes or not - # this ensures that our bundle analysis script always runs and that we always ensure next.js is building - # regardless of having code changes or not - fetch-depth: 1 - - - name: Set up Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 - with: - # We want to ensure that the Node.js version running here respects our supported versions - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install npm packages - # We want to avoid npm from running the Audit Step and Funding messages on a CI environment - # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted - # We also use `--omit=dev` to avoid installing devDependencies as we don't need them during the build step - run: npm i --no-audit --no-fund --userconfig=/dev/null --omit=dev + path: ${{ github.workspace }}/apps/site/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}- - - name: Build Next.js (ISR) + - name: Build Next.js # We want a ISR build on CI to ensure that regular Next.js builds work as expected. - # We want to enforce that the actual `turbo@latest` package is used instead of a possible hijack from the user - # the `${{ steps.turborepo_arguments.outputs.turbo_args }}` is a string substitution coming from a previous step - run: npx --package=turbo@latest -- turbo build ${{ steps.turborepo_arguments.outputs.turbo_args }} + run: node_modules/.bin/turbo build --filter=@node-core/website --cache-dir=.turbo/cache env: # We want to ensure we have enough RAM allocated to the Node.js process # this should be a last resort in case by any chances the build memory gets too high # but in general this should never happen NODE_OPTIONS: '--max_old_space_size=4096' - # Used for API requests that require GitHub API scopes - NEXT_GITHUB_API_KEY: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Next.js (Static) - # We only run full static builds within Pull Requests. As they're not needed on `merge_group` or `push` events - # Note that we skip full static builds on Crowdin-based Pull Requests as these PRs should only contain translation changes - if: | - (github.event_name == 'push') || - (github.event_name == 'pull_request_target' && - github.event.pull_request.head.ref != 'chore/crowdin') - # We want to enforce that the actual `turbo@latest` package is used instead of a possible hijack from the user - # the `${{ steps.turborepo_arguments.outputs.turbo_args }}` is a string substitution coming from a previous step - run: npx --package=turbo@latest -- turbo deploy ${{ steps.turborepo_arguments.outputs.turbo_args }} + # See https://github.com/vercel/next.js/pull/90949 + TURBOPACK_STATS: ${{ matrix.os == 'ubuntu-latest' }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: matrix.os == 'ubuntu-latest' + with: + name: webpack-stats + path: apps/site/.next/diagnostics/route-bundle-stats.json + + - name: Build Next.js (Static Export) + # We want to generate a static build, as it is a requirement of our website. + run: node_modules/.bin/turbo deploy --filter=@node-core/website --cache-dir=.turbo/cache env: # We want to ensure we have enough RAM allocated to the Node.js process # this should be a last resort in case by any chances the build memory gets too high # but in general this should never happen NODE_OPTIONS: '--max_old_space_size=4096' - # Used for API requests that require GitHub API scopes - NEXT_GITHUB_API_KEY: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync Orama Cloud - # We only want to sync the Orama Cloud production indexes on `push` events. - # We also want to sync the Orama Cloud preview (deployment) indexes on `pull_request_target` events. - # We also want to ensure that the sync only happens on the `ubuntu-latest` runner to avoid duplicate syncs - # or Windows-based path issues. + # We want to ensure that static exports for all locales do not occur on `pull_request` events + # TODO: The output of this is too large, and it crashes the GitHub Runner + NEXT_PUBLIC_STATIC_EXPORT_LOCALE: false # ${{ github.event_name == 'push' }} + + compare-bundle-size: + name: Compare Bundle Size + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'pull_request' + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Git Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download Stats (HEAD) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: webpack-stats + path: head-stats + + - name: Get Run ID from BASE + id: base-run + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + run: | + ID=$(gh run list -c "$BASE_SHA" -w build.yml -s success -L 1 --json databaseId --jq ".[].databaseId") + echo "run_id=$ID" >> "$GITHUB_OUTPUT" + + - name: Download Stats (BASE) + id: base-stats + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: webpack-stats + path: base-stats + run-id: ${{ steps.base-run.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Compare Bundle Size + id: compare-bundle-size + if: steps.base-stats.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + HEAD_STATS_PATH: ./head-stats/route-bundle-stats.json + BASE_STATS_PATH: ./base-stats/route-bundle-stats.json + with: + script: | + const { compare } = await import('${{github.workspace}}/apps/site/scripts/compare-size/index.mjs') + await compare({core}) + + - name: Prepare Comment + if: steps.base-stats.outcome == 'success' env: - ORAMA_INDEX_ID: ${{ github.event_name == 'push' && secrets.ORAMA_PRODUCTION_INDEX_ID || secrets.ORAMA_INDEX_ID }} - ORAMA_SECRET_KEY: ${{ github.event_name == 'push' && secrets.ORAMA_PRODUCTION_SECRET_KEY || secrets.ORAMA_SECRET_KEY }} - if: | - (matrix.os == 'ubuntu-latest') && - ((github.event_name == 'push') || (github.event_name == 'pull_request_target')) + COMMENT: ${{ steps.compare-bundle-size.outputs.comment }} run: | - cd apps/site && npm run sync-orama + mkdir -p pr-comment + printf '%s' "$COMMENT" > pr-comment/comment.md + printf '%s' 'compare_bundle_size' > pr-comment/tag.txt + + - name: Upload Comment + if: steps.base-stats.outcome == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-comment + path: pr-comment/ diff --git a/.github/workflows/bump-security-txt-expiry.yml b/.github/workflows/bump-security-txt-expiry.yml new file mode 100644 index 0000000000000..97cb139347bf1 --- /dev/null +++ b/.github/workflows/bump-security-txt-expiry.yml @@ -0,0 +1,65 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Bump security.txt Expiry + +on: + workflow_dispatch: + schedule: + - cron: '0 8 1 1 *' # once a year, Jan 1 at 08:00 UTC + +permissions: {} + +jobs: + bump-expiry: + runs-on: ubuntu-latest + if: github.repository_owner == 'nodejs' + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Bump the Expires year + id: update-expiry + env: + SECURITY_TXT: apps/site/public/.well-known/security.txt + run: | + set -euo pipefail + old=$(sed -n 's/^Expires: *//p' "$SECURITY_TXT") + year=${old%%-*} + new="$((year + 1))${old#"$year"}" + sed -i "s|^Expires: .*|Expires: ${new}|" "$SECURITY_TXT" + { + echo "old=${old}" + echo "new=${new}" + } >> "$GITHUB_OUTPUT" + + - name: Open pull request + uses: gr2m/create-or-update-pull-request-action@b65137ca591da0b9f43bad7b24df13050ea45d1b # v1.10.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + update-pull-request-title-and-body: true + branch: chore/update-security-txt + body: | + Updates the `Expiry` on our [`security.txt`](https://nodejs.org/.well-known/security.txt) from ${{ steps.update-expiry.outputs.old }} to ${{ steps.update-expiry.outputs.new }} + + Before merging, please explicitly confirm that: + - The security contact listed is still correct and actively monitored + - The linked security policy is still accurate + - The project's preferred vulnerability disclosure path has not changed + + cc @nodejs/security-wg + + Check this workflow's logs at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. + commit-message: 'meta: update security `Expiry` from ${{ steps.update-expiry.outputs.old }} to ${{ steps.update-expiry.outputs.new }}' + title: 'meta: update security `Expiry` from ${{ steps.update-expiry.outputs.old }} to ${{ steps.update-expiry.outputs.new }}' + draft: true + labels: security diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000000000..48af4d5ab7836 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,72 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Chromatic + +on: + merge_group: + push: + branches: + - main + paths: + - packages/ui-components/** + - .github/workflows/chromatic.yml + pull_request: + branches: + - main + paths: + - packages/ui-components/** + - .github/workflows/chromatic.yml + workflow_dispatch: + +defaults: + run: + # This ensures that the working directory is the root of the repository + working-directory: ./ + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'push' }} + +permissions: + contents: read + actions: read + +jobs: + chromatic: + # We only need to run Storybook Builds and Storybook Visual Regression Tests within Pull Requests that actually + # introduce changes to the Storybook. Hence, we skip running these on Crowdin PRs and Dependabot PRs + if: | + github.event_name != 'pull_request' || + ( + github.actor != 'dependabot[bot]' && + github.event.pull_request.head.ref != 'chore/crowdin' + ) + + name: Chromatic + runs-on: ubuntu-latest + + environment: + name: Storybook + url: ${{ steps.chromatic-deploy.outputs.storybookUrl }} + + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + fetch-depth: 0 + + - name: Start Visual Regression Tests (Chromatic) + # This assigns the Environment Deployment for Storybook + id: chromatic-deploy + uses: chromaui/action@14cfaef73576e69f95f47f60058063f46ca38719 # v18.1.0 + with: + workingDir: packages/ui-components + buildScriptName: storybook:build + projectToken: ${{ vars.CHROMATIC_PROJECT_TOKEN }} + exitOnceUploaded: true + onlyChanged: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f84f2efb54e8..d62bbce624f35 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: 'CodeQL' on: @@ -17,6 +6,7 @@ on: pull_request: # The branches below must be a subset of the branches above branches: ['main'] + types: [opened, synchronize, reopened, ready_for_review] schedule: - cron: '0 0 * * 1' @@ -26,53 +16,8 @@ permissions: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + uses: nodejs/web-team/.github/workflows/codeql.yml@9f3c83af227d721768d9dbb63009a47ed4f4282f permissions: actions: read contents: read security-events: write - - strategy: - fail-fast: false - matrix: - language: ['javascript', 'typescript'] - # CodeQL supports [ $supported-codeql-languages ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 - with: - category: '/language:${{matrix.language}}' diff --git a/.github/workflows/create-release-post.yml b/.github/workflows/create-release-post.yml new file mode 100644 index 0000000000000..a008351424834 --- /dev/null +++ b/.github/workflows/create-release-post.yml @@ -0,0 +1,67 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Create Release Blog Post + +on: + workflow_dispatch: + inputs: + version: + description: The version to generate a blog post for. + type: string + required: true + +defaults: + run: + # This ensures that the working directory is the root of the repository + working-directory: ./ + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version }} + cancel-in-progress: false + +jobs: + create-post: + name: Create Release Blog Post + runs-on: ubuntu-latest + permissions: + # Required to push the release branch + contents: write + # Required to create the pull request + pull-requests: write + + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + + - run: node --run scripts:release-post -- "$VERSION" + working-directory: apps/site + id: release-post + env: + VERSION: ${{ inputs.version }} + + - name: Open pull request + uses: gr2m/create-or-update-pull-request-action@b65137ca591da0b9f43bad7b24df13050ea45d1b # v1.10.1 + # Creates a PR or update the Action's existing PR, or + # no-op if the base branch is already up-to-date. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + update-pull-request-title-and-body: true + branch: release-${{ inputs.version }} + body: | + Creates a new blog post for ${{ inputs.version }} + + Check this workflow's logs at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. + commit-message: 'feat(blog): create post for ${{ inputs.version }}' + labels: fast-track + title: 'feat(blog): create post for ${{ inputs.version }}' + assignees: ${{ steps.release-post.outputs.author }} + draft: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 34f959f461871..3b0d4f38d0255 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,32 +1,18 @@ -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Request, -# surfacing known-vulnerable versions of the packages declared or updated in the PR. -# Once installed, if the workflow run is marked as required, -# PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action name: Review Dependencies on: - pull_request_target: + pull_request: branches: - main +# Cancel any runs on the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: dependency-review: - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Review Dependencies - uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 + uses: nodejs/web-team/.github/workflows/dependency-review.yml@9f3c83af227d721768d9dbb63009a47ed4f4282f diff --git a/.github/workflows/leave-comment.yml b/.github/workflows/leave-comment.yml new file mode 100644 index 0000000000000..d79ec042481d5 --- /dev/null +++ b/.github/workflows/leave-comment.yml @@ -0,0 +1,97 @@ +name: Leave Comment + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] + # While it's true (as reported by Zizmor) that many `workflow_run` + # workflows are unsafe, we are using it purely to paste a code comment + # on a pull request. If an attacker were to attempt to modify the _only_ + # file of data we collect from their unsafe code, the worst they could do + # is leave a comment on their own PR, which has no harmful impact + # whatsover. + # + # Any Workflow that uploads a `pr-comment` artifact should be listed here + workflows: ['Build', 'Lighthouse'] + types: [completed] + +permissions: + contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + leave-comment: + name: Leave Comment + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Download Comment Artifact + # The Workflow may not have produced a comment (e.g. the comparison was skipped), so this is + # allowed to fail and every subsequent step is gated on it having succeeded. + id: download + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pr-comment + path: pr-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve Pull Request Number + id: pr + if: steps.download.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run; + + // 1. For same-repo Pull Requests the run is already linked to its PR(s). + if (run.pull_requests && run.pull_requests.length) { + core.setOutput('number', run.pull_requests[0].number); + return; + } + + // 2. For forks that list is empty, so find the open Pull Request who has the + // correct branch information + const match = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`, + sort: 'updated', + direction: 'desc', + per_page: 1, + }).then(r => r.data[0]); + + if (!match) { + core.info(`No open pull request found for HEAD ${run.head_sha}`); + return; + } + + core.setOutput('number', match.number); + + - name: Read Comment Tag + id: meta + if: steps.download.outcome == 'success' + run: | + tag="$(tr -cd 'A-Za-z0-9_-' < pr-comment/tag.txt)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Add Comment to PR + # The comment body is untrusted markdown, so it is passed as a file (data) rather than + # interpolated into an expression or shell command. + if: steps.download.outcome == 'success' && steps.pr.outputs.number != '' + uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 # v3.0.0 + with: + file-path: pr-comment/comment.md + comment-tag: ${{ steps.meta.outputs.tag }} + pr-number: ${{ steps.pr.outputs.number }} diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 3a5821a7990d5..b49f72e712bd9 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -1,5 +1,7 @@ # Security Notes -# This workflow uses `pull_request_target`, so will run against all PRs automatically (without approval), be careful with allowing any user-provided code to be run here +# This Workflow runs in the untrusted `pull_request` context and therefore must not rely on any +# repository secrets. It does not comment on the Pull Request itself; instead it uploads a +# `pr-comment` artifact which the trusted `Leave Comment` Workflow posts once this Workflow completes. # Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) # for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. # REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! @@ -9,7 +11,7 @@ name: Lighthouse on: - pull_request_target: + pull_request: branches: - main types: @@ -23,8 +25,10 @@ defaults: permissions: contents: read actions: read - # This permission is required by `thollander/actions-comment-pull-request` - pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: lighthouse-ci: @@ -32,68 +36,57 @@ jobs: if: | startsWith(github.event.pull_request.head.ref, 'dependabot/') == false && github.event.label.name == 'github_actions:pull-request' - name: Lighthouse Report runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - # Provides the Pull Request commit SHA or the GitHub merge group ref - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - - name: Add Comment to PR - # Signal that a lighthouse run is about to start - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - message: | - Running Lighthouse audit... - # Used later to edit the existing comment - comment_tag: 'lighthouse_audit' - - name: Capture Vercel Preview - uses: patrickedqvist/wait-for-vercel-preview@06c79330064b0e6ef7a2574603b62d3c98789125 # v1.3.2 - id: vercel_preview_url + id: deployment + uses: patrickedqvist/wait-for-vercel-preview@d7982701e6fcd3ae073bff929e408e004404d38d # v1.3.3 with: token: ${{ secrets.GITHUB_TOKEN }} - # timeout after 5 minutes - max_timeout: 300 - # check every 10 seconds - check_interval: 10 + max_timeout: 300 # timeout after 5 minutes + check_interval: 10 # check every 10 seconds + + - name: Git Checkout + # Only needed for the Lighthouse formatting script; no credentials are persisted. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Audit Preview URL with Lighthouse # Conduct the lighthouse audit id: lighthouse_audit - uses: treosh/lighthouse-ci-action@1b0e7c33270fbba31a18a0fbb1de7cc5256b6d39 # v11.4.0 + uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12.6.2 with: # Defines the settings and assertions to audit configPath: './.lighthouserc.json' # These URLS capture critical pages / site functionality. urls: | - ${{ steps.vercel_preview_url.outputs.url }}/en - ${{ steps.vercel_preview_url.outputs.url }}/en/about - ${{ steps.vercel_preview_url.outputs.url }}/en/about/previous-releases - ${{ steps.vercel_preview_url.outputs.url }}/en/download - ${{ steps.vercel_preview_url.outputs.url }}/en/blog + ${{ steps.deployment.outputs.url }}/en + ${{ steps.deployment.outputs.url }}/en/about + ${{ steps.deployment.outputs.url }}/en/about/previous-releases + ${{ steps.deployment.outputs.url }}/en/download + ${{ steps.deployment.outputs.url }}/en/download/archive/current + ${{ steps.deployment.outputs.url }}/en/blog uploadArtifacts: true # save results as a action artifacts temporaryPublicStorage: true # upload lighthouse report to the temporary storage - name: Format Lighthouse Score # Transform the audit results into a single, friendlier output id: format_lighthouse_score - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: # using env as input to our script # see https://github.com/actions/github-script#use-env-as-input LIGHTHOUSE_RESULT: ${{ steps.lighthouse_audit.outputs.manifest }} LIGHTHOUSE_LINKS: ${{ steps.lighthouse_audit.outputs.links }} - VERCEL_PREVIEW_URL: ${{ steps.vercel_preview_url.outputs.url }} + VERCEL_PREVIEW_URL: ${{ steps.deployment.outputs.url }} with: # Run as a separate file so we do not have to inline all of our formatting logic. # See https://github.com/actions/github-script#run-a-separate-file for more info. @@ -101,11 +94,16 @@ jobs: const { formatLighthouseResults } = await import('${{github.workspace}}/apps/site/scripts/lighthouse/index.mjs') await formatLighthouseResults({core}) - - name: Add Comment to PR - # Replace the previous message with our formatted lighthouse results - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 + - name: Prepare Comment + env: + COMMENT: ${{ steps.format_lighthouse_score.outputs.comment }} + run: | + mkdir -p pr-comment + printf '%s' "$COMMENT" > pr-comment/comment.md + printf '%s' 'lighthouse_audit' > pr-comment/tag.txt + + - name: Upload Comment + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - # Reference the previously created comment - comment_tag: 'lighthouse_audit' - message: | - ${{ steps.format_lighthouse_score.outputs.comment }} + name: pr-comment + path: pr-comment/ diff --git a/.github/workflows/lint-and-tests.yml b/.github/workflows/lint-and-tests.yml index 446ab0ee2736c..c61cfafcdb3fb 100644 --- a/.github/workflows/lint-and-tests.yml +++ b/.github/workflows/lint-and-tests.yml @@ -1,88 +1,59 @@ # Security Notes # Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) # for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. -# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# REVIEWERS, please always double-check security practices before merging a PR that contains workflow changes!! # AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. name: Linting and Tests +# This workflow should run either on `merge_group`, `pull_request`, or `push` events +# since we want to run lint checks against any changes on pull requests, or the final patch on merge groups +# or if direct pushes happen to main (or when changes in general land on the `main` (default) branch) +# Note that the reason why we run this on pushes against `main` is that on rare cases, maintainers might do direct pushes against `main` + on: push: branches: - main - pull_request_target: + pull_request: branches: - main - types: - - labeled + types: [opened, synchronize, reopened, ready_for_review] merge_group: -defaults: - run: - # This ensures that the working directory is the root of the repository - working-directory: ./ - +# The permissions specified below apply to workflows triggered by `merge_group`, `push`, and `pull_request` events that originate from the same repository (non-fork). +# However, workflows triggered by `pull_request` events from forked repositories are treated differently for security reasons: +# - These workflows **do not** have access to any secrets configured in the repository. +# - They are also **not granted any permissions** to perform actions on the base repository. +# +# This is a deliberate security restriction designed to prevent potential abuse through malicious pull requests from forks. +# For a deeper explanation and best practices for securing your GitHub Actions workflows, particularly against so-called "pwn requests", +# refer to https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ permissions: contents: read actions: read - # This permission is required by `MishaKav/jest-coverage-comment` - pull-requests: write - -jobs: - base: - name: Base Tasks - runs-on: ubuntu-latest - outputs: - turbo_args: ${{ steps.turborepo_arguments.outputs.turbo_args }} - - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - name: Provide Turborepo Arguments - # This step is responsible for providing a reusable string that can be used within other steps and jobs - # that use the `turbo` cli command as a way of easily providing shared arguments to the `turbo` command - id: turborepo_arguments - # See https://turbo.build/repo/docs/reference/command-line-reference/run#--force - run: echo "turbo_args=--force=true" >> "$GITHUB_OUTPUT" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: lint: - # This Job should run either on `merge_groups` or `push` events - # or `pull_request_target` event with a `labeled` action with a label named `github_actions:pull-request` - # since we want to run lint checks against any changes on pull requests, or the final patch on merge groups - # or if direct pushes happen to main (or when changes in general land on the `main` (default) branch) - # Note that the reason why we run this on pushes against `main` is that on rare cases, maintainers might do direct pushes against `main` - if: | - (github.event_name == 'push' || github.event_name == 'merge_group') || - (github.event_name == 'pull_request_target' && - github.event.label.name == 'github_actions:pull-request') - name: Quality checks runs-on: ubuntu-latest - needs: [base] steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f with: - egress-policy: audit - - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - # Provides the Pull Request commit SHA or the GitHub merge group ref - ref: ${{ github.event.pull_request.head.sha || github.ref }} + pnpm: true + use-version-file: true - name: Restore Lint Cache - uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | .turbo/cache - node_modules/.cache .eslintmdcache - .eslintjscache .stylelintcache .prettiercache # We want to restore Turborepo Cache and ESlint and Prettier Cache @@ -90,34 +61,19 @@ jobs: # as they will only run on files that have changed since the last cached run # this might of course lead to certain files not being checked against the linter, but the chances # of such situation from happening are very slim as the checksums of both files would need to match - key: cache-lint-${{ hashFiles('package-lock.json') }}- + key: cache-lint-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('.turbo/cache/**') }} restore-keys: | - cache-lint-${{ hashFiles('package-lock.json') }}- + cache-lint-${{ hashFiles('pnpm-lock.yaml') }}- cache-lint- - - name: Set up Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 - with: - # We want to ensure that the Node.js version running here respects our supported versions - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install npm packages - # We want to avoid npm from running the Audit Step and Funding messages on a CI environment - # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted - run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null - - name: Run quality checks with `turbo` # We run the ESLint and Prettier commands on all Workflow triggers of the `Lint` job, besides if # the Pull Request comes from a Crowdin Branch, as we don't want to run ESLint and Prettier on Crowdin PRs # Note: Linting and Prettifying of files on Crowdin PRs is handled by the `translations-pr.yml` Workflow if: | (github.event_name == 'push' || github.event_name == 'merge_group') || - (github.event_name == 'pull_request_target' && - github.event.pull_request.head.ref != 'chore/crowdin') - # We want to enforce that the actual `turbo@latest` package is used instead of a possible hijack from the user - # the `${{ needs.base.outputs.turbo_args }}` is a string substitution happening from the base job - run: npx --package=turbo@latest -- turbo lint check-types prettier ${{ needs.base.outputs.turbo_args }} + (github.event_name == 'pull_request' && github.event.pull_request.head.ref != 'chore/crowdin') + run: node_modules/.bin/turbo lint lint:types prettier --cache-dir=.turbo/cache - name: Save Lint Cache # We only want to save caches on `push` events or `pull_request_target` events @@ -127,101 +83,42 @@ jobs: # other Pull Requests and PRs coming from forks if: | github.event_name == 'push' || - (github.event_name == 'pull_request_target' && + (github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'dependabot/') == false && github.event.pull_request.head.ref != 'chore/crowdin') - uses: actions/cache/save@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | .turbo/cache - node_modules/.cache .eslintmdcache - .eslintjscache .stylelintcache .prettiercache - key: cache-lint-${{ hashFiles('package-lock.json') }}-${{ hashFiles('.turbo/cache/**') }} + key: cache-lint-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('.turbo/cache/**') }} tests: - # This Job should run either on `merge_groups` or `push` events - # or `pull_request_target` event with a `labeled` action with a label named `github_actions:pull-request` - # since we want to run lint checks against any changes on pull requests and on final patches against a pull request. - # We don't need to execute the tests again on pushes against (`main`) as the merge group should already handle that - if: | - (github.event_name == 'push' || github.event_name == 'merge_group') || - (github.event_name == 'pull_request_target' && - github.event.label.name == 'github_actions:pull-request') - name: Tests runs-on: ubuntu-latest - needs: [base] - - environment: - name: Storybook - url: ${{ steps.chromatic-deploy.outputs.storybookUrl }} steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f with: - egress-policy: audit - - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - # Provides the Pull Request commit SHA or the GitHub merge group ref - ref: ${{ github.event.pull_request.head.sha || github.ref }} - # The Chromatic (@chromaui/action) Action requires a full history of the current branch in order to be able to compare - # previous changes and previous commits and determine which Storybooks should be tested against and what should be built - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 - with: - # We want to ensure that the Node.js version running here respects our supported versions - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install npm packages - # We want to avoid npm from running the Audit Step and Funding messages on a CI environment - # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted - run: npm i --no-audit --no-fund --userconfig=/dev/null + pnpm: true + use-version-file: true - name: Run Unit Tests # We want to run Unit Tests in every circumstance, including Crowdin PRs and Dependabot PRs to ensure # that changes to dependencies or translations don't break the Unit Tests - # We want to enforce that the actual `turbo@latest` package is used instead of a possible hijack from the user - # the `${{ needs.base.outputs.turbo_args }}` is a string substitution happening from the base job - run: npx --package=turbo@latest -- turbo test:unit ${{ needs.base.outputs.turbo_args }} -- --ci --coverage + run: node --run test:ci -- --cache-dir=.turbo/cache - - name: Start Visual Regression Tests (Chromatic) - # This assigns the Environment Deployment for Storybook - id: chromatic-deploy - # We only need to run Storybook Builds and Storybook Visual Regression Tests within Pull Requests that actually - # introduce changes to the Storybook. Hence, we skip running these on Crowdin PRs and Dependabot PRs - if: | - github.event_name == 'push' || - (github.event_name == 'pull_request_target' && - startsWith(github.event.pull_request.head.ref, 'dependabot/') == false && - github.event.pull_request.head.ref != 'chore/crowdin') - # sha reference has no stable git tag reference or URL. see https://github.com/chromaui/chromatic-cli/issues/797 - uses: chromaui/action@fdbe7756d4dbf493e2fbb822df73be7accd07e1c + - name: Upload test coverage to Codecov + if: ${{ !cancelled() && github.event_name != 'merge_group' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - workingDir: apps/site - buildScriptName: storybook:build - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - exitOnceUploaded: true - onlyChanged: true + files: ./apps/site/lcov.info,./packages/*/lcov.info - - name: Jest Coverage Comment - # We don't need to post the Jest Coverage comment on Crowdin PRs and Dependabot PRs - # as in general they introduce no changes to the Unit Tests and the Codebase - # We reuse the checks from the Chromatic Deploy step as they're the same conditions - if: steps.chromatic-deploy.outcome == 'success' - # This comments the current Jest Coverage Report containing JUnit XML reports - # and a Code Coverage Summary - uses: MishaKav/jest-coverage-comment@434e6d2d37116d23d812809b61d499639842fa3b # v1.0.26 + - name: Upload test results to Codecov + if: ${{ !cancelled() && github.event_name != 'merge_group' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - title: 'Unit Test Coverage Report' - junitxml-path: ./apps/site/junit.xml - junitxml-title: Unit Test Report - coverage-summary-path: ./apps/site/coverage/coverage-summary.json + report_type: test_results + files: ./apps/site/junit.xml,./packages/*/junit.xml diff --git a/.github/workflows/notify-on-push.yml b/.github/workflows/notify-on-push.yml new file mode 100644 index 0000000000000..9ac17633e30bd --- /dev/null +++ b/.github/workflows/notify-on-push.yml @@ -0,0 +1,24 @@ +on: + push: + branches: + - main + +name: Notify on Push +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: false + +jobs: + notify_on_push: + name: Notify on any direct push to `main` + if: > + github.repository == 'nodejs/nodejs.org' && + github.actor != 'github-merge-queue[bot]' + runs-on: ubuntu-latest + steps: + - uses: nodejs/web-team/actions/notify-on-push@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + webhook: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/playwright-cloudflare-open-next.yml b/.github/workflows/playwright-cloudflare-open-next.yml new file mode 100644 index 0000000000000..b049a3d08ddde --- /dev/null +++ b/.github/workflows/playwright-cloudflare-open-next.yml @@ -0,0 +1,64 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Playwright Tests on Cloudflare Open-Next + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + playwright: + name: Playwright Tests + runs-on: ubuntu-latest + + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + fetch-depth: 2 + + - name: Get Playwright version + id: playwright-version + working-directory: apps/site + run: echo "version=$(node_modules/.bin/playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright Browsers + working-directory: apps/site + run: node_modules/.bin/playwright install --with-deps + + - name: Build open-next site + run: node_modules/.bin/turbo build --filter=@node-core/platform-cloudflare --cache-dir=.turbo/cache + + - name: Run Playwright tests + run: pnpm --filter=@node-core/platform-cloudflare test:e2e + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: apps/site/playwright-report/ diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000000..f09764aafd418 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,72 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. +# MERGE QUEUE NOTE: This Workflow does not run on `merge_group` trigger, as this Workflow is not required for Merge Queue's + +name: Playwright Tests + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + playwright: + # We want to skip on Dependabot PRs + if: startsWith(github.event.pull_request.head.ref, 'dependabot/') == false + name: Playwright Tests + runs-on: ubuntu-latest + + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + fetch-depth: 2 + + - name: Capture Vercel Preview + id: deployment + uses: patrickedqvist/wait-for-vercel-preview@d7982701e6fcd3ae073bff929e408e004404d38d # v1.3.3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + max_timeout: 300 # timeout after 5 minutes + check_interval: 10 # check every 10 seconds + + - name: Get Playwright version + id: playwright-version + working-directory: apps/site + run: echo "version=$(node_modules/.bin/playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright Browsers + working-directory: apps/site + run: node_modules/.bin/playwright install --with-deps + + - name: Run Playwright tests + working-directory: apps/site + run: node --run playwright + env: + PLAYWRIGHT_BASE_URL: ${{ steps.deployment.outputs.url }} + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: apps/site/playwright-report/ diff --git a/.github/workflows/pnpm-updater.yml b/.github/workflows/pnpm-updater.yml new file mode 100644 index 0000000000000..6419dcd780c71 --- /dev/null +++ b/.github/workflows/pnpm-updater.yml @@ -0,0 +1,70 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: PNPM updater + +on: + workflow_dispatch: + schedule: + - cron: '0 0 1 * *' + +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update-pnpm: + name: Update pnpm + runs-on: ubuntu-latest + permissions: + # Required to push changes to the update branch + contents: write + # Required to create the pull request + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Git Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Update pnpm and package.json + id: update-pnpm + run: | + echo "old=$(pnpm --version)" >> $GITHUB_OUTPUT + pnpm self-update + echo "new=$(pnpm --version)" >> $GITHUB_OUTPUT + cat <<< $(jq '.devEngines.packageManager.version = (.packageManager | split("@")[1])' package.json) > package.json + + - name: Open pull request + if: steps.update-pnpm.outputs.old != steps.update-pnpm.outputs.new + uses: gr2m/create-or-update-pull-request-action@b65137ca591da0b9f43bad7b24df13050ea45d1b # v1.10.1 + # Creates a PR or update the Action's existing PR, or + # no-op if the base branch is already up-to-date. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + update-pull-request-title-and-body: true + branch: chore/update-pnpm + body: | + Updates pnpm from ${{ steps.update-pnpm.outputs.old }} to ${{ steps.update-pnpm.outputs.new }} + + cc @nodejs/web-infra + + Check this workflow's logs at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. + commit-message: 'meta: update pnpm from ${{ steps.update-pnpm.outputs.old }} to ${{ steps.update-pnpm.outputs.new }}' + title: 'meta: update pnpm from ${{ steps.update-pnpm.outputs.old }} to ${{ steps.update-pnpm.outputs.new }}' + draft: true diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000000000..c12447643fd12 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,104 @@ +name: Publish Packages + +# Changesets opens or updates a version PR when release notes are present. Once that PR is merged, +# this workflow publishes the versioned packages to npm. + +on: + push: + # For security reasons, this should never be set to anything but `main` + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + COMMIT_SHA: ${{ github.sha }} + +jobs: + release: + name: Create Release PR or Publish + runs-on: ubuntu-latest + # Never attempt to publish from forks (no trusted-publisher match / secrets). + if: github.repository == 'nodejs/nodejs.org' + permissions: + contents: write + id-token: write + pull-requests: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Verify commit authenticity + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get commit data from GitHub API to verify its authenticity + COMMIT_DATA=$(gh api repos/${{ github.repository }}/commits/$COMMIT_SHA) + # Check if commit signature is verified (GPG signed) + VERIFIED=$(echo "$COMMIT_DATA" | jq -r '.commit.verification.verified') + # Check if commit was made through GitHub's web interface (merge queue) + COMMITTER=$(echo "$COMMIT_DATA" | jq -r '.commit.committer.email') + + # Security checks to ensure we only publish from verified and trusted sources + if [[ "$VERIFIED" != "true" ]]; then + echo "❌ Unverified commit! Aborting." + exit 1 + fi + + if [[ "$COMMITTER" != "noreply@github.com" ]]; then + echo "❌ Not merged with the merge queue! Aborting." + exit 1 + fi + + echo "✅ Commit is verified and trusted." + + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + registry-url: 'https://registry.npmjs.org' + fetch-depth: 0 + + - name: Create release pull request or publish packages + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + commit: 'chore: version packages' + title: 'chore: version packages' + version: node --run changeset:version + publish: node --run release + commitMode: github-api + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Format published packages + if: steps.changesets.outputs.published == 'true' + id: notification + env: + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: | + { + echo 'packages<)"' <<< "$PUBLISHED_PACKAGES" + echo 'EOF' + } >> "$GITHUB_OUTPUT" + + - name: Notify + if: steps.changesets.outputs.published == 'true' + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 + env: + SLACK_COLOR: '#43853D' + SLACK_ICON: https://github.com/nodejs.png?size=48 + SLACK_TITLE: ':rocket: Packages Published' + SLACK_MESSAGE: | + ${{ steps.notification.outputs.packages }} + :bust_in_silhouette: *Published by*: ${{ github.triggering_actor }} + :octocat: *Commit*: + SLACK_USERNAME: nodejs-bot + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/pull-request-label.yml b/.github/workflows/pull-request-label.yml index 1a6d4b2a985da..4642bc449cc78 100644 --- a/.github/workflows/pull-request-label.yml +++ b/.github/workflows/pull-request-label.yml @@ -18,9 +18,11 @@ defaults: # This ensures that the working directory is the root of the repository working-directory: ./ -permissions: - # This permission is required by `actions-ecosystem/action-remove-label` - pull-requests: write +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: # This Job removes the `github_actions:pull-request` label after it got applied @@ -29,9 +31,12 @@ jobs: remove_pull_request_label: name: Remove Pull Request Label runs-on: ubuntu-latest + permissions: + # Required by `actions-ecosystem/action-remove-label` to remove labels + pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit diff --git a/.github/workflows/pull-request-policy.yml b/.github/workflows/pull-request-policy.yml new file mode 100644 index 0000000000000..2277df7ecdb70 --- /dev/null +++ b/.github/workflows/pull-request-policy.yml @@ -0,0 +1,54 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Pull Request Policy + +on: + pull_request: + branches: + - main + paths: + - packages/** + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changesets: + name: Changesets + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request' && + !(github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'changeset-release/main') + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + fetch-depth: 0 + + - run: pnpm changeset status --since origin/main + + conventional-commits: + name: Conventional commits + runs-on: ubuntu-latest + steps: + - name: Check PR Title Format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + REGEX="^[a-z][a-z0-9-]*(\([a-z0-9_.\/-]+\))?!?: .+" + + if [[ ! "$PR_TITLE" =~ $REGEX || "$PR_TITLE" == *. ]]; then + echo 'PR titles must use [optional scope][!]: and must not end with a period.' + exit 1 + fi diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b8bbe208b4294..026761dc3e4fd 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,7 +1,3 @@ -# This workflow uses actions that are not certified by GitHub. They are provided -# by a third party and are governed by separate terms of service, privacy -# policy and support documentation. - name: OpenSSF Scorecard Review on: # For Branch-Protection check. Only the default branch is supported. See @@ -15,13 +11,12 @@ on: branches: - main -# Declare default permissions as read only. -permissions: read-all +permissions: {} jobs: analysis: name: Scorecard analysis - runs-on: ubuntu-latest + uses: nodejs/web-team/.github/workflows/scorecard.yml@b62c434f5e530041c288a40280567849449e74be permissions: # Needed to upload the results to code-scanning dashboard. security-events: write @@ -29,36 +24,3 @@ jobs: id-token: write contents: read actions: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - persist-credentials: false - - - name: Run Scorecard Analysis - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: Upload Artifacts - uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: Upload Scan Results - uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 - with: - sarif_file: results.sarif diff --git a/.github/workflows/tmp-cloudflare-open-next-deploy.yml b/.github/workflows/tmp-cloudflare-open-next-deploy.yml new file mode 100644 index 0000000000000..da8cba163dda9 --- /dev/null +++ b/.github/workflows/tmp-cloudflare-open-next-deploy.yml @@ -0,0 +1,63 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +# Note: this is a temporary workflow for deploying the OpenNext version of the site (hosted on https://node-test.org) +# this version of the site is just used for testing purposes and ideally we want to keep it in sync with the +#  official website + +name: Deploy the Cloudflare OpenNext test version of the site (https://node-test.org) + +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + deploy: + name: Deploy Open-Next site + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Git Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # We want to ensure that the Node.js version running here respects our supported versions + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install packages + run: pnpm install --frozen-lockfile + + - name: Build open-next site + run: node_modules/.bin/turbo build --filter=@node-core/platform-cloudflare --cache-dir=.turbo/cache + + - name: Deploy open-next site + run: pnpm --filter=@node-core/platform-cloudflare run deploy + env: + CF_WORKERS_SCRIPTS_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: 07be8d2fbc940503ca1be344714cb0d1 diff --git a/.github/workflows/translations-pr-lint.yml b/.github/workflows/translations-pr-lint.yml new file mode 100644 index 0000000000000..d6529c6f06e19 --- /dev/null +++ b/.github/workflows/translations-pr-lint.yml @@ -0,0 +1,58 @@ +# This Workflow is used to comment on PRs that have changes that touch Translated Files +# and then comments on their PRs mentioning that they should not do so + +name: Incoming Translation Checks + +on: + # run when someone tries to manually change localized content + pull_request_target: + branches: + - main + paths: + - 'apps/site/pages/**/*.md' + - 'apps/site/pages/**/*.mdx' + - '!apps/site/pages/en/**/*.md' + - '!apps/site/pages/en/**/*.mdx' + - 'packages/i18n/src/locales/*.json' + - '!packages/i18n/src/locales/en.json' + - 'apps/site/snippets/**/*.bash' + - '!apps/site/snippets/en/**/*.bash' + +# Cancel any runs on the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + comment_on_translation_pr: + # This comment should always be posted on forks, or from internal PRs not originating from Crowdin (which are direct branches) + if: | + (github.event.pull_request.head.repo.full_name != 'nodejs/nodejs.org') || + (github.event.pull_request.head.repo.full_name == 'nodejs/nodejs.org' && github.event.pull_request.head.ref != 'chore/crowdin') + + name: Comment on Translation PR + runs-on: ubuntu-latest + + permissions: + # This permission is required by `thollander/actions-comment-pull-request` + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 # v3.0.0 + with: + message: | + > [!NOTE]\ + > Your Pull Request seems to be updating **Translations** of the Node.js Website. + > + > Whilst we appreciate your intent; Any Translation update should be done through our [Crowdin Project](https://crowdin.com/project/nodejs-web). + > We recommend giving a read on our [Translation Guidelines](https://github.com/nodejs/nodejs.org/blob/main/docs/translation.md). + > + > Thank you! + comment-tag: use_crowdin diff --git a/.github/workflows/translations-pr.yml b/.github/workflows/translations-pr.yml deleted file mode 100644 index 09f4a29c4c9a9..0000000000000 --- a/.github/workflows/translations-pr.yml +++ /dev/null @@ -1,128 +0,0 @@ -# This Workflow is used to comment on PRs that have changes that touch Translated Files -# and then comments on their PRs mentioning that they should not do so - -name: Crowdin Checks - -on: - pull_request_target: - branches: - - main - paths: - - 'apps/site/pages/**/*.md' - - 'apps/site/pages/**/*.mdx' - - '!apps/site/pages/en/**/*.md' - - '!apps/site/pages/en/**/*.mdx' - - 'apps/site/i18n/locales/*.json' - - '!apps/site/i18n/locales/en.json' - -permissions: - actions: read - -jobs: - comment_on_translation_pr: - # This comment should always be posted on forks, or from internal PRs not originating from Crowdin (which are direct branches) - if: | - (github.event.pull_request.head.repo.full_name != 'nodejs/nodejs.org') || - (github.event.pull_request.head.repo.full_name == 'nodejs/nodejs.org' && github.event.pull_request.head.ref != 'chore/crowdin') - - name: Comment on Translation PR - runs-on: ubuntu-latest - - permissions: - # This permission is required by `thollander/actions-comment-pull-request` - pull-requests: write - - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - message: | - > [!NOTE]\ - > Your Pull Request seems to be updating **Translations** of the Node.js Website. - > - > Whilst we appreciate your intent; Any Translation update should be done through our [Crowdin Project](https://crowdin.com/project/nodejs-web). - > We recommend giving a read on our [Translation Guidelines](https://github.com/nodejs/nodejs.org/blob/main/TRANSLATION.md). - > - > Thank you! - comment_tag: use_crowdin - - format_crowdin_pull_request: - # We should only run the automated Format Command on Crowdin-based Pull Requests - if: | - github.event.pull_request.head.repo.full_name == 'nodejs/nodejs.org' && - github.event.pull_request.head.ref == 'chore/crowdin' - - name: Format Crowdin Pull Request - runs-on: ubuntu-latest - - permissions: - # This permission is required by `stefanzweifel/git-auto-commit-action` - contents: write - - steps: - - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 - with: - egress-policy: audit - - - name: Git Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - # Provides the Pull Request commit SHA or the GitHub merge group ref - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - - name: Restore Lint Cache - uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 - with: - path: | - apps/site/.eslintmdcache - apps/site/.prettiercache - # We want to restore Turborepo Cache and ESlint and Prettier Cache - # The ESLint and Prettier cache's are useful to reduce the overall runtime of ESLint and Prettier - # as they will only run on files that have changed since the last cached run - # this might of course lead to certain files not being checked against the linter, but the chances - # of such situation from happening are very slim as the checksums of both files would need to match - key: cache-lint-${{ hashFiles('package-lock.json') }}- - restore-keys: | - cache-lint-${{ hashFiles('package-lock.json') }}- - cache-lint- - - - name: Set up Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 - with: - # We want to ensure that the Node.js version running here respects our supported versions - node-version-file: '.nvmrc' - cache: 'npm' - - - name: Install npm packages - # We want to avoid npm from running the Audit Step and Funding messages on a CI environment - # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted - run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null - - - name: Run `npx lint:md --fix` - # This runs a specific version of ESLint with only the Translation Pages Globbing - # This avoid that unrelated changes get linted/modified within this PR - run: npx eslint "apps/site/pages/**/*.md?(x)" --fix --cache --cache-strategy=metadata --cache-file=apps/site/.eslintmdcache - - - name: Run `npx prettier --write` - # This runs a specific version of Prettier with only the Translation Pages Globbing - # This avoid that unrelated changes get prettied/modified within this PR - run: npx prettier "apps/site/{pages,i18n}/**/*.{json,md,mdx}" --check --write --cache --cache-strategy=metadata --cache-location=apps/site/.prettiercache - - - name: Push Changes back to Pull Request - uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1 - with: - commit_options: '--no-verify --signoff' - commit_message: 'chore: automated format of translated files' - - - name: Save Lint Cache - uses: actions/cache/save@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 - with: - path: | - apps/site/.eslintmdcache - apps/site/.prettiercache - key: cache-lint-${{ hashFiles('package-lock.json') }}-${{ hashFiles('apps/site/.eslintmdcache') }} diff --git a/.github/workflows/translations-sync.yml b/.github/workflows/translations-sync.yml new file mode 100644 index 0000000000000..241455aafe1e6 --- /dev/null +++ b/.github/workflows/translations-sync.yml @@ -0,0 +1,128 @@ +# This action automates the synchronization of our crowdin translations, so that a human does not need to kick it off from the crowdin UI +# It also formats incoming content because it is often not adherent to our rules post-translation. + +# See translations-upload.yml for automation to upload our source content +# See translations-pr-lint.yml for quality control we conduct on ingress of new translations. +name: Crowdin Download + +on: + workflow_dispatch: # Allow running when we want to, for events such as urgent translation mistakes or 100% completed languages + schedule: + - cron: '0 5 * * 5' # At 05:00 on Fridays. This guarantees that we have the 72 hour weekend time to review translations. + +# Cancel any runs on the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + BRANCH_NAME: chore/crowdin + +jobs: + synchronize-with-crowdin: + name: Synchronize with Crowdin + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.CROWDIN_GITHUB_BOT_TOKEN }} + persist-credentials: false + + # see all the options at https://github.com/crowdin/github-action + - name: Crowdin PR + uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 + with: + # do not upload anything - this is a one-way operation download + upload_sources: false + upload_translations: false + # the rest of this controls how the PR comes in with new translations + download_translations: true + localization_branch_name: ${{ env.BRANCH_NAME }} + create_pull_request: true + pull_request_title: 'chore(i18n): sync translations from crowdin' + pull_request_body: 'New Crowdin translations from the [Node.js Crowdin project](https://crowdin.com/project/nodejs-web)' + commit_message: 'chore(i18n): sync translations from crowdin' + env: + GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_BOT_TOKEN }} + # A numeric ID, found at https://crowdin.com/project/nodejs-web/tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + # Created from https://crowdin.com/settings#api-key logged in using nodejs-crowdin-bot + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + format_crowdin_pull_request: + name: Format Crowdin Pull Request + needs: synchronize-with-crowdin + runs-on: ubuntu-latest + + permissions: + # This permission is required by `stefanzweifel/git-auto-commit-action` + contents: write + + steps: + - uses: nodejs/web-team/actions/setup-environment@9f3c83af227d721768d9dbb63009a47ed4f4282f + with: + pnpm: true + use-version-file: true + ref: ${{ env.BRANCH_NAME }} + token: ${{ secrets.CROWDIN_GITHUB_BOT_TOKEN }} + fetch-depth: 2 + + - name: Restore Lint Cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + apps/site/.eslintmdcache + apps/site/.prettiercache + # We want to restore Turborepo Cache and ESlint and Prettier Cache + # The ESLint and Prettier cache's are useful to reduce the overall runtime of ESLint and Prettier + # as they will only run on files that have changed since the last cached run + # this might of course lead to certain files not being checked against the linter, but the chances + # of such situation from happening are very slim as the checksums of both files would need to match + key: cache-lint-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/site/.eslintmdcache') }} + restore-keys: | + cache-lint-${{ hashFiles('pnpm-lock.yaml') }}- + cache-lint- + + - name: Add changeset if the files changed + run: | + CHANGED_FILES=$(git diff --name-only HEAD^1 HEAD) + if [ -n "$CHANGED_FILES" ]; then + cat > .changeset/crowdin-translations.md <<'EOF' + --- + '@node-core/website-i18n': patch + --- + + Update translations from Crowdin. + EOF + fi + + - name: Run ESLint + working-directory: apps/site + run: node --run lint:md -- --fix + + - name: Run Prettier + run: node --run prettier:fix + + - name: Push Changes back to Pull Request + uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 + with: + commit_options: '--no-verify --signoff' + commit_message: 'chore(i18n): format translated files' + branch: ${{ env.BRANCH_NAME }} + + - name: Save Lint Cache + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + apps/site/.eslintmdcache + apps/site/.prettiercache + key: cache-lint-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/site/.eslintmdcache') }} diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml new file mode 100644 index 0000000000000..c561c7df98750 --- /dev/null +++ b/.github/workflows/translations-upload.yml @@ -0,0 +1,47 @@ +# This action automates the upload of our source content to crowdin. +# See translations-sync.yml for the automation to download new translations on a schedule +# See translations-pr-lint.yml for quality control we conduct on ingress of new translations. +name: Crowdin Upload + +on: + push: + branches: [main] + +# Cancel any runs on the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + upload-to-crowdin: + name: Upload to Crowdin + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # see all the options at https://github.com/crowdin/github-action + - name: crowdin action + uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 + with: + # only upload sources, ensuring this is a one-way operation + upload_sources: true + upload_translations: false + download_translations: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # A numeric ID, found at https://crowdin.com/project/nodejs-web/tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + # Created from https://crowdin.com/settings#api-key logged in using nodejs-crowdin-bot + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000000000..f2e1f2f2c7fb8 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,32 @@ +name: Zizmor + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + zizmor: + name: Zizmor Security Analysis + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 diff --git a/.gitignore b/.gitignore index aef8ec50a2dbe..a756afeb5c8ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,28 @@ # Commonly ignored Node.js files node_modules +.env.* + +# npm Compatibility +# https://github.com/nodejs/nodejs.org/discussions/5334#discussioncomment-12827850 npm-debug.log .npm -.env.* +package-lock.json # Next.js Build Output -.next -build +apps/site/.next +apps/site/build +apps/site/public/blog-data.json +apps/site/next-env.d.ts + +# Generated Build Artifacts +platforms/cloudflare/generated -# Jest -coverage -.swc +# Test Runner junit.xml +lcov.info + +# Distributed Files +dist # Storybook storybook-static @@ -24,13 +35,24 @@ build-storybook.log cache # Cache Files -.eslintjscache .eslintmdcache .stylelintcache .prettiercache # TypeScript tsconfig.tsbuildinfo +dist/ + +# Cloudflare Build Output +apps/site/.open-next +apps/site/.wrangler + +## Playwright +test-results +playwright-report + +## MacOS Ignored Files +.DS_Store -# Sentry Config File -.sentryclirc +## Other Files +.env diff --git a/.husky/pre-commit b/.husky/pre-commit index 0b30eeabf5b20..5f31f933a76bf 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,7 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - # lint and format staged files -npx lint-staged -# verify typescript staged files -npx tsc --build apps/site +node --run lint:staged + +# verify typescript fully + +node --run lint:types diff --git a/.lighthouserc.json b/.lighthouserc.json index eb1c22031ae30..be61cf8385d95 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -3,7 +3,8 @@ "collect": { "numberOfRuns": 1, "settings": { - "preset": "desktop" + "preset": "desktop", + "skipAudits": ["is-crawlable"] } }, "assert": { diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 4b80a42c97bcb..91a0e156e4ab9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,8 +1,3 @@ { - "**/*.{js,mjs,ts,tsx,md,mdx}": [ - "turbo run lint:lint-staged --", - "prettier --check --write" - ], - "**/*.css": ["stylelint --allow-empty-input", "prettier --write"], - "**/*.{json,yml}": ["prettier --check --write"] + "**/*.{js,mjs,ts,tsx,md,mdx,json.yml}": ["prettier --check --write"] } diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 4a440c6a74c7e..0000000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -# See bug in peerdeps resolution: https://github.com/npm/cli/issues/2999 -legacy-peer-deps = false diff --git a/.nvmrc b/.nvmrc index 209e3ef4b6247..54c65116f15a6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +v24 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 723e338d33061..0000000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -repos: -- repo: https://github.com/gitleaks/gitleaks - rev: v8.16.3 - hooks: - - id: gitleaks -- repo: https://github.com/jumanjihouse/pre-commit-hooks - rev: 3.0.0 - hooks: - - id: shellcheck -- repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.38.0 - hooks: - - id: eslint -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: end-of-file-fixer - - id: trailing-whitespace diff --git a/.prettierignore b/.prettierignore index 513b005d5c368..b0a2a81b9e2e2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,19 +1,20 @@ # Commonly ignored Node.js files node_modules + +# npm Compatibility +# https://github.com/nodejs/nodejs.org/discussions/5334#discussioncomment-12827850 npm-debug.log .npm +package-lock.json # Next.js Build Output .next build +next-env.d.ts -# Next.js Generated Files -public/static/documents - -# Jest -coverage -.swc +# Test Runner junit.xml +lcov.info # Storybook storybook-static @@ -26,7 +27,6 @@ build-storybook.log cache # Cache Files -.eslintjscache .eslintmdcache .stylelintcache .prettiercache @@ -37,6 +37,12 @@ tsconfig.tsbuildinfo # Metadata Files CODEOWNERS +# Public Folders +apps/site/public + +# Distributed Files +dist + # Prettier's Handlebar parser is limited and chokes on some syntax features # https://github.com/prettier/prettier/issues/11834 scripts/release-post/template.hbs diff --git a/.vscode/settings.json b/.vscode/settings.json index a7e0023eb6be3..f45e0fc02c2f8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "javascript.updateImportsOnFileMove.enabled": "always", - "typescript.updateImportsOnFileMove.enabled": "always" + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 0978d490b386e..0000000000000 --- a/CODEOWNERS +++ /dev/null @@ -1,23 +0,0 @@ -# Website Development -* @nodejs/nodejs-website - -# Infrastructure -.github @nodejs/web-infra -.husky @nodejs/web-infra - -# Framework -app/site/next.config.mjs @nodejs/web-infra -app/site/next.dynamic.mjs @nodejs/web-infra - -# Node.js Release Blog Posts -app/site/pages/en/blog/release @nodejs/releasers -app/site/pages/en/blog/announcements @nodejs/releasers - -# Package Ecosystem -package.json @nodejs/nodejs-website -turbo.json @nodejs/nodejs-website @nodejs/web-infra - -# Web Infrastructure -crowdin.yml @nodejs/web-infra -app/site/redirects.json @nodejs/web-infra -app/site/site.json @nodejs/web-infra diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 8fff30e0a6cca..0000000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,4 +0,0 @@ -# Code of Conduct - -- [Node.js Code of Conduct](https://github.com/nodejs/admin/blob/HEAD/CODE_OF_CONDUCT.md) -- [Node.js Moderation Policy](https://github.com/nodejs/admin/blob/HEAD/Moderation-Policy.md) diff --git a/COLLABORATOR_GUIDE.md b/COLLABORATOR_GUIDE.md deleted file mode 100644 index be844b55769b7..0000000000000 --- a/COLLABORATOR_GUIDE.md +++ /dev/null @@ -1,505 +0,0 @@ -# Node.js Collaborator Guide - -- [Issues and Pull Requests](#issues-and-pull-requests) -- [Accepting Modifications](#accepting-modifications) - - [Involving the Website Team](#involving-the-website-team) -- [Technologies used in the Website](#technologies-used-in-the-website) -- [Code editing](#code-editing) - - [Adding new pages](#adding-new-pages) - - [Create the page content](#create-the-page-content) - - [Translating pages](#translating-pages) -- [Creating Components](#creating-react-components) - - [Styling a Component](#styling-a-component) - - [Best practices when creating a Component](#best-practices-when-creating-a-component) - - [How a new Component should look like when freshly created](#how-a-new-component-should-look-like-when-freshly-created) - - [Best practices for Component development in general](#best-practices-for-component-development-in-general) -- [Unit Tests and Storybooks](#unit-tests-and-storybooks) - - [General Guidelines for Unit Tests](#general-guidelines-for-unit-tests) - - [General Guidelines for Storybooks](#general-guidelines-for-storybooks) -- [Remarks on Technologies used](#remarks-on-technologies-used) -- [Seeking additional clarification](#seeking-additional-clarification) - -This document contains information for Collaborators of the Node.js website project regarding maintaining the code, documentation, and issues. - -Collaborators should be familiar with the guidelines for new contributors in [CONTRIBUTING.md](./CONTRIBUTING.md). - -## Issues and Pull Requests - -Courtesy should always be shown to individuals submitting issues and pull requests to the Node.js website project. - -Collaborators should feel free to take full responsibility for managing issues and pull requests they feel qualified to handle, as long as this is done while being mindful of these guidelines, the opinions of other Collaborators and guidance of the Website Group. - -Collaborators may **close** any issue or pull request they believe is not relevant to the future of the Node.js project. -Where this is unclear, the issue should be left open for several days for additional discussion. -Where this does not yield input from Node.js Collaborators or additional evidence that the issue has relevance, then the issue may be closed. -Remember that issues can always be re-opened if necessary. - -> \[!IMPORTANT]\ -> We recommend Collaborators to avoid Updating/Rebasing PRs unnecessarily, since we use [GitHub Merge Queues](https://github.blog/2023-07-12-github-merge-queue-is-generally-available/) -> to merge Pull Requests, which automatically rebases and runs CI-checks against the latest base branch. - -## Accepting Modifications - -All Node.js code and documentation modifications should be performed via GitHub pull requests. -Only the Website Team can merge their work and should do so carefully. - -All pull requests must be reviewed and accepted by a Collaborator with sufficient expertise who can take full responsibility for the change. -In the case of pull requests proposed by an existing Collaborator, an additional Collaborator is required for sign-off. - -Pull Requests can only be merged after all CI Checks have passed. -As usual, CI Checks need to be manually triggered by adding a `github_actions:pull-request` label to the Pull Request. - -In some cases, it may be necessary to summon a qualified Collaborator to a pull request for review by @-mention. - -If you are unsure about the modification and are not prepared to take full responsibility for the change, defer to another Collaborator. - -We recommend collaborators follow the guidelines on the [Contributing Guide](./CONTRIBUTING.md#before-merging) for reviewing and merging Pull Requests. - -### Involving the Website Team - -Collaborators may opt to elevate pull requests or issues to the group for discussion by mentioning `@nodejs/nodejs-website`. This should be done where a pull request: - -- has a significant impact on the codebase, -- is inherently controversial; or -- has failed to reach a consensus amongst the Collaborators who are actively participating in the discussion. - -The Website group should be the final arbiter where needed. - -## Technologies used in the Website - -The Node.js Website is built upon [React][] and [Next.js][] respectively, the UI Rendering Engine and the Framework that builds the Website; - -The Website also uses several other Open Source libraries (not limited to) listed below: - -- Styling is done with [PostCSS][] and CSS Modules - - We use a combination of PostCSS plugins to create a [Sass](https://sass-lang.com/) alike environment - - We recommend reading the documentation of our plugins in case of doubt - - [PostCSS Mixins](https://github.com/postcss/postcss-mixins) - - [PostCSS Import](https://github.com/postcss/postcss-import) - - [PostCSS Simple Vars](https://github.com/postcss/postcss-simple-vars) -- [Tailwind][] is used as our CSS Framework and the Foundation of our Design System -- [Hero Icons](https://heroicons.com/) is an SVG Icon Library used within our Codebase -- [Radix UI][] is a collection of customizable UI components -- [Shiki][] is a Syntax Highlighter used for our Codeboxes - - The syntax highlighting is done within the processing of the Markdown files with the MDX compiler as a Rehype plugin. -- [MDX][] and Markdown are used for structuring the Content of the Website -- [`next-intl`][] is the i18n Library adopted within the Website - - It provides an excellent integration with Next.js, But it also supports standalone support for i18n if it eventually migrates from Next.js to something else. - - Supports React Server Components and Next.js Middlewares -- [`next-sitemap`](https://www.npmjs.com/package/next-sitemap) is used for Sitemap and `robots.txt` Generation -- We use [Rehype](https://github.com/rehypejs/rehype) and [Remark](https://github.com/remarkjs/remark) to extend MDX functionality -- We use [Storybook](https://storybook.js.org/) for Manual Testing and Visual Regression Tests of our React Components - - Storybook also provides a sandboxed environment, which is very useful whilst for crafting React Components -- We use [Sentry](https://sentry.io/about) for reporting Exceptions and monitoring the performance and reliability of the application - -## Code Editing - -### Structure of this Repository - -âš ī¸ The repository is actively under migration to a multi-package workspace. -Locations are subject to change. (If you are someone updating these paths, -please document those changes here.) - -- React Components are defined on `apps/site/components` -- React Templates are defined on `apps/site/layouts` -- Global Stylesheets are declared on `apps/site/styles` - - Styles are done with [PostCSS][] -- Public files are stored on `apps/site/public` - - Static Images, JavaScript files, and others are stored within `apps/site/public/static` -- Internationalisation is done on `apps/site/i18n` - - React Localisation Data is stored within `apps/site/i18n/locales` - - We use the [ICU Message Syntax](https://formatjs.io/docs/core-concepts/icu-syntax/) for Translations - - Configuration for Locales is done within `apps/site/i18n/config.json` -- Website Content is defined within `apps/site/pages` - - Initial development usually happens in English: `apps/site/pages/en` - - Localized versions of `/pages/en` are done within `apps/site/pages/{localeCode}` - - All content is in Markdown and is per locale. - - The top of each Markdown file is a YAML (Frontmatter) block for page-specific localization information passed to various templates. - - The bulk of the Markdown content for each page is referenced as `{children}` on their respective JSX Layout (`apps/site/layouts/`) -- Multi-Purpose React Hooks are defined on `apps/site/hooks` -- Multi-Purpose TypeScript definitions are defined on `apps/site/types` -- React Context Providers are defined on `apps/site/providers` -- Build-time Data Fetching Scripts are defined on `apps/site/next-data` - - Used for Node.js Release data fetching - - Generation of build-time indexes such as blog data -- Multi-Purpose Scripts are stored within `apps/site/scripts` - - Such as Node.js Release Blog Post generation -- Storybook Configuration is done within `apps/site/.storybook` - - We use an almost out-of-the-box Storybook Experience with a few extra customisations - -### Adding new Pages - -1. Create new page content including the layout, title and copy. -2. Update the relevant `apps/site/layout` to add a link to the new page. - -#### Create the page content - -Create a new markdown file in `apps/site/pages/en`. - -At the top of the markdown file, within the Markdown Frontmatter, set a page the title and layout. - -```markdown ---- -title: Title of the Page -layout: layout-name ---- - -[Content of the Page] -``` - -> \[!NOTE]\ -> A list of currently available Layouts is provided within `components/withLayout` on the `layoutComponents` map.\ -> This is a temporary map and this map might change its location and be defined in a different way in the future. - -### Translating Pages - -See the [Translation Guidelines](./TRANSLATION.md) for the website translation policy. - -## Creating React Components - -The Node.js Website uses [React][] as a Frontend Library to develop the Website. -React allows us to create user interfaces with a modern take on Web Development. - -If you're unfamiliar with React or Web Development in general, we encourage a read before taking on complex issues and tasks as this repository is **not for educational purposes** and we expect you to have a basic understanding of the technologies used. - -We also recommend getting familiar with technologies such as [Next.js][], [MDX][], [PostCSS][], and "concepts" such as "CSS Modules" and "CSS-in-JS". - -### Styling a Component - -As mentioned, we write all Component-styles in separate `.module.css` files. This is like writing any CSS in a separate file (besides the fact that we use [PostCSS][]). - -This concept of writing styles on dedicated CSS files and importing them within JavaScript (or React) is a pattern named **[CSS Module](https://github.com/css-modules/css-modules)**. -These allow us to write PostCSS (or regular CSS, or any flavor of CSS if you have a way of interpreting it) within a `.module.css` and import the class names directly to our React Components. -We recommend reading guides on "Styling React Components with CSS Modules", which there are many available on the web. - -It's important to mention that we use [Tailwind][] as a CSS Framework. Hence, margins, paddings, font sizes, font weights, colors, and other sorts of styles are all provided with Tailwind. -We recommend reading [Tailwind Docs](https://tailwindcss.com/docs/preflight) to get familiar with Tailwind's styles. -We also recommend reading [this guide for setting up Tailwind on your IDE](https://tailwindcss.com/docs/editor-setup). - -Finally, if you're unfamiliar with how to use Tailwind or how to use Tailwind within CSS Modules, we recommend reading [this guide](https://tailwindcss.com/docs/using-with-preprocessors). - -#### Example of a CSS Module - -```css -.myComponent { - @apply some - tailwind - classes; -} -``` - -#### Guidelines when writing CSS - -- We use camelCase for defining CSS classes -- We use Tailwind's `@apply` selector to apply Tailwind Tokens - - We discourage the usage of any plain CSS styles and tokens, when in doubt ask for help - - We require that you define one Tailwind Token per line, just as shown on the example above, since this improves readability -- Only write CSS within CSS Modules, avoid writing CSS within JavaScript files -- We recommend creating mixins for reusable animations, effects and more - - You can create Mixins within the `apps/site/styles/mixins` folder - -> \[!NOTE]\ -> Tailwind is already configured for this repository. You don't need to import any Tailwind module within your CSS module.\ -> You can apply Tailwind Tokens with Tailwind's `@apply` CSS rule. [Read more about applying Tailwind classes with `@apply`](https://tailwindcss.com/docs/functions-and-directives#apply). - -> \[!IMPORTANT]\ -> When using IDEs such as Visual Studio Code, we recommend installing the official [Stylelint](https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint) -> and [Tailwind](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) Extensions.\ -> These are recommended Extensions for IntelliSense, Syntax Highlighting and Error Checking when styling your Component. - -### Best practices when creating a Component - -- All React Components should be placed within the `apps/site/components` folder. -- Each Component should be placed, whenever possible, within a sub-folder, which we call the "Domain" of the Component - - The domain represents where these Components belong to or where they will be used. - - For example, Components used within Article Pages or that are part of the structure of an Article or the Article Layouts, - should be placed within `apps/site/components/Article` -- Each component should have its folder with the name of the Component -- The structure of each component folder follows the following template: - ```text - - ComponentName - - index.tsx // the component itself - - index.module.css // all styles of the component are placed there - - index.stories.tsx // component Storybook stories - - __tests__ // component tests (such as unit tests, etc) - - index.test.mjs // unit tests should be done in ESM and not TypeScript - ``` -- React Hooks belonging to a single Component should be placed within the Component's folder - - If the Hook as a wider usability or can be used by other components, it should be placed in the root `hooks` folder. -- If the Component has "sub-components" they should follow the same philosophy as the Component itself. - - For example, if the Component `ComponentName` has a sub-component called `SubComponentName`, - then it should be placed within `ComponentName/SubComponentName` - -#### How a new Component should look like when freshly created - -```tsx -import type { FC } from 'react'; - -import styles from './index.module.css'; - -type MyComponentProps = {}; // The types of the Props of your Component - -const MyComponent: FC = ({ prop1, prop2... }) => ( - // Actual code of my Component -); - -export default MyComponent; -``` - -### Best practices for Component development in general - -- Only spread props `{ ... }` on the definition of the Component (Avoid having a variable named `props`) -- Avoid importing `React`, only import the modules from React that you need -- When importing types, use `import type { NameOfImport } from 'module'` -- When defining a Component, use the `FC` type from React to define the type of the Component - - When using `children` as a prop, use the `FC>` type instead - - Alternatively you can define your type as `type MyComponentProps = PropsWithChildren<{ my other props }>` -- Each Props type should be prefixed by the name of the Component -- Components should always be the `default` export of a React Component file -- Avoid using DOM/Web APIs/`document`/`window` API access within a React Component. - Use utilities or Hooks when you need a Reactive state -- Avoid making your Component too big. Deconstruct it into smaller Components/Hooks whenever possible - -## Unit Tests and Storybooks - -Each new feature or bug fix should be accompanied by a unit test (when deemed valuable). -We use [Jest][] as our test runner and [React Testing Library][] for our React unit tests. - -We also use [Storybook][] to document our components. -Each component should have a storybook story that documents the component's usage. - -Visual Regression Testing is automatically done via [Chromatic](https://www.chromatic.com/) to ensure that Components are rendered correctly. - -### General Guidelines for Unit Tests - -Unit Tests are fundamental to ensure that code changes do not disrupt the functionalities of the Node.js Website: - -- Unit tests should be written as `.mjs` files. -- We recommend adding unit tests for content covering `util`, `scripts`, `hooks`, and `components` whenever possible. -- Unit Tests should ensure that a given change's functionality is working as expected. -- When creating unit tests for React components, we recommend that the tests cover all the possible states of the component. -- We also recommend mocking external dependencies, if unsure about how to mock a particular dependency, raise the question on your Pull Request. - - We recommend using [Jest's Mock Functions](https://jestjs.io/docs/en/mock-functions) for mocking dependencies. - - We recommend using [Jest's Mock Modules](https://jestjs.io/docs/en/manual-mocks) for mocking dependencies unavailable on the Node.js runtime. - - Common Providers and Contexts from the lifecycle of our App, such as [`next-intl`][] should not be mocked but given an empty or fake context whenever possible. -- We recommend reading previous unit tests from the codebase for inspiration and code guidelines. - -### General Guidelines for Storybooks - -Storybooks are an essential part of our development process. They help us to document our components and to ensure that the components are working as expected. - -They also allow Developers to preview Components and be able to test them manually/individually to the smallest unit of the Application. (The individual Component itself). - -**Storybooks should be fully typed and follow the following template:** - -```tsx -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import NameOfComponent from '@components/PathTo/YourComponent'; - -type Story = StoryObj; -type Meta = MetaObj; - -// If the component has any props that are interactable, they should be passed here -// We recommend reading Storybook docs for args: https://storybook.js.org/docs/react/writing-stories/args -export const Default: Story = {}; - -// If the Component has more than one State/Layout/Variant, there should be one Story for each variant -export const AnotherStory: Story = { - args: {}, -}; - -export default { component: NameOfComponent } as Meta; -``` - -- Stories should have `args` whenever possible, we want to be able to test the different aspects of a Component -- Please follow the template above to keep the Storybooks as consistent as possible -- We recommend reading previous Storybooks from the codebase for inspiration and code guidelines. -- If you need to decorate/wrap your Component/Story with a Container/Provider, please use [Storybook Decorators](https://storybook.js.org/docs/react/writing-stories/decorators) - -## Remarks on Technologies Used - -The Node.js Website is a somewhat complex application and at times non-trivial solutions have been implemented to solve certain technical challenges. -Historical decision making can be largely found on past issues, conversations on Slack and GitHub discussions. However, we also wish to highlight some of the notable development decisions that we have made here. - -### Why Next.js? - -We've found that Next.js is simply versatile, hackable, stable, community-maintained and has a great ecosystem. -The reasoning goes deeper, but as a long-term Framework it is the most suitable choice. - -#### Why do we continue to support static builds? - -It was decided together with the TSC (Technical Steering Committee) that the Node.js Website should always support fully static builds that do not depend on any 3rd party services. -This is to ensure that the Website is always available and that we do not depend on any 3rd party services to serve our content. - -(For example, if we abandon Vercel, our Website should still completely work as standalone as possible) - -#### What is `next.dynamic.mjs`? - -Our whole Website uses a custom renderer for rendering the pages. -As you might have seen, within the `apps/site/pages` directory we have [Next.js Dynamic Route](https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes) named `[...path].tsx` that matches against all possible routes of the Website. - -This means that each `.md(x)` file within `apps/site/pages/` is not rendered by Next.js regular App Tree (`apps/site/pages/_document.tsx` and `apps/site/pages/_app.tsx`) but a custom render tree. - -This custom render uses `getStaticPaths` and [Incremental Static Generation](https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration) to generate the full list of supported pages of the Website. -For example, this allows us to generate Localized Pages for every page that is not translated, by telling Next.js to create a localised path. -`next.dynamic.mjs` is responsible for getting a full list of the source pages (`apps/site/pages/en`) and identifying which pages have been translated. - -Non-translated pages will have their Localized contexts and translated React message-bags (`next-intl`) but the content will be the same as the source page (English). -Whereas localized pages will have localized context and content. - -This custom solution is also able to decide what paths should be compiled during runtime. -This is a combination of rules defined on `next.constants.mjs` and `[...path].tsx`. - -The `[...path].tsx` file ultimately utilizes the `theme.tsx` file as its layout source. -This setup enables the loading of the Layout Provider and MDX Provider, which in turn, encapsulate and manage the rendering of any child content or components. This includes both content and components provided by the Layout Provider and the transformed MDX content originating from the `.md(x)` source page. - -#### What is `next.data.mjs`? - -This file is responsible for loading, fetching and generating build-time required information such as Node.js Release data, Blog Posts Metadata (for pagination and indexation), RSS feeds, etc. - -#### What is `site.json`? - -This file is used for defining Website Metadata, such as which RSS feeds should be generated, Social Media Information and other Metadata used during the Website build-time. -We use a JSON format to ease collaboration. - -#### What is `next.locales.mjs` and why not use Next.js built-in i18n? - -While Next.js provides a built-in i18n feature, it doesn't offer the flexibility we require. Our specific needs include the ability to generate comprehensive lists of supported locales and pages for each locale. Additionally, we operate with a subfolder-based approach for content, as opposed to the extension-based approach (e.g., `filename.language.md(x)`) that is compatible with Next.js's built-in i18n. - -We opted for the subfolder approach to maintain consistency with our previous Node.js website's content structure and to ensure long-term maintainability, rather than relying on Next.js's i18n functionality. - -#### What is `next.rewrites.mjs`? - -This file is responsible for defining the rewrite rules for the Website. -It is used for defining redirects and other rewrite rules. (Such as Internal Redirects and External ones). - -The `redirects.json` file specifies two types of operations: rewrites and redirects. - -- Rewrites: These operations do not alter the original URL but instead render the content from the rewritten URL. It's important to note that the rewritten URL path must be valid and exist on the website. -- Redirects: Redirect operations, on the other hand, change the original URL to a new one. While these new URLs can be either internal (within the website) or external (leading to a different domain), it is more common for redirects to point externally. - -This file contains a simple template engine that supports `/:locale` to indicate that this path should also be available under all available locales as prefix. - -#### Why do we use Next.js Middlewares? - -We have a simple Next.js Middleware that is responsible for handling initial Locale detection and redirection. -It detects browser locales and redirects to the most suitable locale for the user. And it fallbacks to `/en` if no suitable locale is found. - -#### What are Layouts? - -Layouts Wrap the content of the Markdown files. -They are responsible for adding additional styling and structure surrounding the content of the Markdown files. - -Layouts are defined within the `layouts` folder. -They are React Components that receive the `children` prop, which is the transformed MDX content of the Markdown file. - -Each Page layout is configured within their Markdown's Frontmatter as `layout: name-of-layout`. - -### How we style the Website? - -We use [PostCSS][] to style the Node.js Website; PostCSS is a CSS Preprocessor, like Sass and Less. - -#### How exactly do we style Components? - -We style each individual React Component with a dedicated CSS file (A CSS Module) that uses CSS syntax (with the extra powerups of PostCSS). - -The [Styling a Component](#styling-a-component) section contains a more detailed guide on how we style our Components. - -#### Why we use PostCSS over Sass or Less? - -The main advantage of PostCSS is its minimal pluggable API that allows us to extend the native CSS-syntax with custom plugins. - -Next.js natively supports PostCSS and always uses PostCSS as part of the bundling and building process. -By not using Sass or Less we remove another dependency from our build process and remove one layer of preprocessing our styles (CSS). - -We currently use a set of PostCSS plugins that create a SCSS-a-like environment. - -#### What PostCSS Plugins we use? - -- `postcss-mixins`: Allows us to use Sass-like Mixins -- `postcss-import`: Allows us to use Sass-like Imports -- `postcss-simple-vars`: Allows us to use Sass-like Variables -- `postcss-nested`: Allows us to use Sass-like Nesting -- `postcss-calc`: Strips `calc` expressions and replaces them with the result - -It is important to mention that even though we use SCSS-like syntax, we do not use SCSS, and some of these plugins -are not 100% compatible with the SCSS syntax. -For example, `postcss-mixins` does not support `@include` and `@extend` directives (and it uses `@define-mixin` for defining Mixins and `@mixin` for including Mixins). - -#### Do we use a CSS Framework? - -The Node.js Website uses Tailwind as a CSS Framework for crafting our React Components and style the Website. - -[Tailwind][] is an utility-first CSS Framework. It allows us to create a Design System that is easy to maintain and extend. It also allows us to create a consistent Design Language across the Website. - -#### Font Families on the Website - -We use `next/fonts` Open Sans as the default font for the Node.js Website. -The font is configured as a CSS variable and then configured on `tailwind.config.js` as the default font for the Website. - -#### Why we use RadixUI? - -- It is a minimalistic component library broken down in individual packages for each Component -- It already handles all WAI-ARIA and Accessibility shortcuts/bindings needed for Interactive Elements -- It allows us to focus on designing interactive Components without the effort of adding all the surrounding sugar and code needed to make the Component accessibility-friendly. - -### Why MDX? - -MDX is an extension on Markdown that allows us to add JSX Components within Markdown. -Besides that, MDX is also a pluggable parser built on top of `unified` which supports Rehype and Remark Plugins. -MDX is becoming the standard for parsing human-content on React/Next.js-based Applications. - -**Some of the plugins that we use include:** - -- `remark-gfm`: Allows us to bring GitHub Flavoured Markdown within MDX -- `remark-headings`: Generates Metadata for Markdown Headings - - This allows us to build the Table of Contents for each Page, for example. -- `rehype-autolink-headings`: Allows us to add Anchor Links to Markdown Headings -- `rehype-slug`: Allows us to add IDs to Markdown Headings - -#### Syntax Highlighting (Shiki) and Vercel - -Shiki is integrated on our workflow as a Rehype Plugin, see the `next.mdx.shiki.mjs` file. We also use the `nord` theme for Shiki and a subset of the supported languages as defined on the `shiki.config.mjs` file. - -### Vercel - -We use Vercel as our hosting provider. It is a great platform that offers an excellent CI/CD pipeline which allows us to deploy our website with ease. - -It is important to mention that there are some rules on our Vercel Deployments such as: - -- Branches starting with `dependabot` (Dependabot Automated PRs) or `gh` (GitHub Merge Queues) are not deployed to Vercel. -- Vercel Deployments are triggered for all other branches during `push` activities. -- We have a custom install script that executes `npm ci --omit=dev` (the same way we do on our CI Pipelines) - - Hence if Builds fail unexpectedly, make sure that your dependency that is being used during build-time is on `dependencies` and not `devDependencies`. Checkout out [DEPENDENCY_PINNING.md](./DEPENDENCY_PINNING.md) for more information. -- Our sponsorship with Vercel is maintained by the OpenJS Foundation - -### Why we have a `.vscode` folder - -The repository defines an optimized configuration for code editing. This is optional and is not required to contribute to the project. However, the settings and extensions specified help create a uniform and more efficient developer experience. This configuration is found in the `.vscode` directory: - -- `extensions.json` suggests VSCode extensions that make the editor more compatible with the code. For example, a Tailwind extension creates auto-complete intellisense for tailwind styles within our components. Eslint, prettier, and editorconfig extensions read their respective config files and automatically format or lint code as written. This helps save CI feedback loops when a contribution does not meet our standards. -- `settings.json` contains some common sense defaults that aide development and enforce consistency across the codebase. For example, we want files formatted on save and we want prettier to be used as the formatter. Without these settings, new contributors may have different authoring experiences when contributing, leading to inconsistent code and CI failures. We also disable VSCode's default CSS parser so PostCSS and Tailwind syntax are respected. - -Defining a `.vscode` configuration like this also aides browser-only development using [GitHub's Codespaces feature](https://github.com/features/codespaces). The web-based GUI will read these same configuration files and setup the remote development environment the same way every time. - -### Why we have an `.npmrc` file - -The npm ecosystem resolution and installation of `peerDependencies` installation [changed in recent versions](https://nodejs.org/en/blog/npm/peer-dependencies#using-peer-dependencies). The project documents what version of `Node.js` and `npm` to use via the [`.nvmrc` file](https://github.com/nodejs/nodejs.org/blob/main/.nvmrc). Not all contributors have tooling to automatically read this file and adhere to the correct version, however. To ensure all contributors install dependencies the same way, a local `.npmrc` file directly configures peerDependency installation behavior. - -## Seeking additional clarification - -A lot of the current structure is due to retro-compatibility, keeping a simple and familiar file structure and keeping files that have historical reasons or needs. - -If you're unfamiliar or curious about something, we recommend opening a Discussion on this GitHub Repository. - -[Jest]: https://jestjs.io/ -[React Testing Library]: https://testing-library.com/docs/react-testing-library/intro/ -[Storybook]: https://storybook.js.org/ -[`next-intl`]: https://next-intl-docs.vercel.app -[Next.js]: https://nextjs.org/ -[MDX]: https://mdxjs.com/ -[PostCSS]: https://postcss.org/ -[React]: https://react.dev/ -[Shiki]: https://github.com/shikijs/shiki -[Tailwind]: https://tailwindcss.com/ -[Radix UI]: https://www.radix-ui.com/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8faae1e317af5..20fdec94a24b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,243 +1,143 @@ # Node.js Website Contributing Guide -Thank you for your interest in contributing to the Node.js Website. Before you proceed, briefly go through the following: +## Table of Contents -- [Code of Conduct](https://github.com/nodejs/node/blob/HEAD/CODE_OF_CONDUCT.md) -- [Contributing](#contributing) - - [Becoming a collaborator](#becoming-a-collaborator) -- [Getting started](#getting-started) - - [CLI Commands](#cli-commands) -- [Commit Guidelines](#commit-guidelines) -- [Pull Request Policy](#pull-request-policy) -- [Developer's Certificate of Origin 1.1](#developers-certificate-of-origin-11) +- [Quick Start](#quick-start) +- [Code of Conduct](#code-of-conduct) + - [Developer's Certificate of Origin 1.1](#developers-certificate-of-origin-11) +- [Ways to Contribute](#ways-to-contribute) + - [For All Contributors](#for-all-contributors) + - [For Developers](#for-developers) +- [Development Workflow](#development-workflow) + - [1. Set Up Your Environment](#1-set-up-your-environment) + - [2. Make Your Changes](#2-make-your-changes) + - [3. Test Your Changes](#3-test-your-changes) + - [4. Submit Your Contribution](#4-submit-your-contribution) +- [Documentation Structure](#documentation-structure) +- [Getting Help](#getting-help) +- [Project Maintainers](#project-maintainers) +- [License](#license) -## Contributing +--- -Any individual is welcome to contribute to the Node.js Website. The repository currently has two kinds of contribution personas: +Thank you for your interest in contributing to the Node.js Website! This guide will help you get started with contributing to our project. -- A **Contributor** is any individual who creates an issue/PR, comments on an issue/PR, or contributes in some other way. -- A **Collaborator** is a contributor with write access to the repository. See [here](#becoming-a-collaborator) on how to become a collaborator. +## Quick Start -You can find more details and guides about Collaborating with this repository through our [Collaborator Guide](./COLLABORATOR_GUIDE.md). +New to contributing? Start here: -### Becoming a Collaborator +1. **[Getting Started](./docs/getting-started.md)** - Set up your development environment and make your first contribution +2. **[Code Style](./docs/code-style.md)** - Learn our coding standards and formatting guidelines +3. **[Adding Pages](./docs/adding-pages.md)** - Create new pages and content for the website -A collaborator of the Node.js Website repository is a member of the Node.js Website Team. +## Code of Conduct -The Website Team is responsible for the technical development of the Node.js Website; thus, it is expected -that team members have significant knowledge about modern Web Technologies and Web Standards. +Before contributing, please read and follow our [Code of Conduct](https://github.com/nodejs/node/blob/HEAD/CODE_OF_CONDUCT.md). -Note that regular contributors do not need to become "Collaborators" as any contribution is appreciated (even without a status), and a Collaborator status -is a formality that comes with obligations. +### Developer's Certificate of Origin 1.1 -If you're an active contributor seeking to become a member, we recommend you contact one of the existing Team Members for guidance. - -
- What's the process for becoming a Collaborator? - -- You must be actively contributing to this repository. -- Contributions must include significant code reviews or code contributions. -- A nomination must be done by an existing Team Member of the Website Team with an Issue - - The Issue must explain and describe why the nominated person is a good addition to the team - - The Issue must contain links to relevant contributions through: - - Code Reviews - - Comments on Issues and PRs - - Authoring of PRs or Issues - - Comments or Authoring of Discussions -- The nomination must have at least three existing members of the Website Team agree with the nomination. - - This can be done through commenting with "agreement" (showing support) or reacting to the Issue with a :+1: (Thumbs-up Emoji) -- The Issue must be open for at least 72 hours without an objection from an existing member of the Website Team - - The nomination cannot pass until all open objections are resolved. - - Objections from the TSC or Core Collaborators are also counted as valid objections. - -
- -## Getting started - -The steps below will give you a general idea of how to prepare your local environment for the Node.js Website and general steps -for getting things done and landing your contribution. - -1. Click the fork button in the top right to clone the [Node.js Website Repository](https://github.com/nodejs/nodejs.org/fork) - -2. Clone your fork using SSH, GitHub CLI, or HTTPS. - - ```bash - git clone git@github.com:/nodejs.org.git # SSH - git clone https://github.com//nodejs.org.git # HTTPS - gh repo clone /nodejs.org # GitHub CLI - ``` - -3. Change into the nodejs.org directory. - - ```bash - cd nodejs.org - ``` - -4. Create a remote to keep your fork and local clone up-to-date. - - ```bash - git remote add upstream git@github.com:nodejs/nodejs.org.git # SSH - git remote add upstream https://github.com/nodejs/nodejs.org.git # HTTPS - gh repo sync nodejs/nodejs.org # GitHub CLI - ``` - -5. Create a new branch for your work. - - ```bash - git checkout -b name-of-your-branch - ``` - -6. Run the following to install the dependencies and start a local preview of your work. - - ```bash - npm ci # installs this project's dependencies - npm run dev # starts a development environment - ``` - -7. Perform your changes. In case you're unfamiliar with the structure of this repository, we recommend a read on the [Collaborator Guide](./COLLABORATOR_GUIDE.md) - -8. Perform a merge to sync your current branch with the upstream branch. - - ```bash - git fetch upstream - git merge upstream/main - ``` - -9. Run `npm run format` to confirm that linting and formatting are passing. - - ```bash - npm run format - ``` - -10. Once you're happy with your changes, add and commit them to your branch, then push the branch to your fork. - - ```bash - cd ~/nodejs.org - git add . - git commit #let commitizen handle the commit - git push -u origin name-of-your-branch - ``` - -> [!IMPORTANT]\ -> Before committing and opening a Pull Request, please go first through our [Commit](#commit-guidelines) and [Pull Request](#pull-request-policy) guidelines outlined below. +``` +By contributing to this project, I certify that: -11. Create a Pull Request. +- (a) The contribution was created in whole or in part by me and I have the right to + submit it under the open source license indicated in the file; or +- (b) The contribution is based upon previous work that, to the best of my knowledge, + is covered under an appropriate open source license and I have the right under that + license to submit that work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am permitted to submit under a + different license), as indicated in the file; or +- (c) The contribution was provided directly to me by some other person who certified + (a), (b) or (c) and I have not modified it. +- (d) I understand and agree that this project and the contribution are public and that + a record of the contribution (including all personal information I submit with it, + including my sign-off) is maintained indefinitely and may be redistributed consistent + with this project or the open source license(s) involved. -> [!NOTE]\ -> We ask for PR authors to avoid to rebase/update their PRs with the base branch (`main`) unnecessarily. -> We use [GitHub Merge Queues](https://github.blog/2023-07-12-github-merge-queue-is-generally-available/) -> which means that before merge the PRs get automatically updated and checked against the latest changes on the base branch. -> -> This also reduces the amount of times we need to run our CI checks, as every new push requires fresh new CI-checks. +``` -### CLI Commands +## Ways to Contribute -This repository contains several scripts and commands for performing numerous tasks. The most relevant ones are described below. +### For All Contributors -
- Commands for Running & Building the Website +- **Report Issues**: Found a bug or have a feature request? [Open an issue](https://github.com/nodejs/nodejs.org/issues/new/choose) +- **Improve Documentation**: Help make our docs clearer and more comprehensive +- **Add Content**: Create new articles, blog posts, or improve existing content +- **Fix Bugs**: Look for issues labeled [`good first issue`](https://github.com/nodejs/nodejs.org/labels/good%20first%20issue) +- **Translate Content**: Help make Node.js documentation accessible worldwide -- `npm run dev` runs Next.js's Local Development Server, listening by default on `http://localhost:3000/`. -- `npm run build` builds the Application on Production mode. The output is by default within `.next` folder. - - This is used for the Node.js Vercel Deployments (Preview & Production) -- `npx turbo deploy` builds the Application on Export Production Mode. The output is by default within `build` folder. - - This is used for the Node.js Legacy Website Server (DigitalOcean) -- `npx turbo start` starts a web server running serving the built content from `npm run build` +### For Developers -
+- **Create Components**: Build reusable React components following our [component guidelines](./docs/creating-components.md) +- **Write Tests**: Improve test coverage with our [testing guidelines](./docs/writing-tests.md) +- **Enhance Features**: Add new functionality to improve user experience -
- Commands for Maintenance Tasks and Tests +## Development Workflow -- `npm run lint` runs the linter for all files. - - `npm run lint:fix` attempts to fix any linting errors -- `npm run prettier` runs the prettier for all the js files. - - `npm run prettier:fix` attempts to fix any style errors -- `npx turbo format` formats and fixes lints for the whole codebase -- `npm run scripts:release-post` in the `apps/site` directory generates a release post for the current release - - **Usage:** `npm run scripts:release-post -- --version=vXX.X.X --force` -- `npx turbo storybook` starts Storybook's local server -- `npx turbo storybook:build` builds Storybook as a static web application for publishing -- `npm run test` runs all tests locally - - `npx turbo test:unit` runs jest (unit-tests) locally +### 1. Set Up Your Environment -
+```bash +# Fork and clone the repository +git clone https://github.com/YOUR_USERNAME/nodejs.org.git +cd nodejs.org -## Commit Guidelines +# Install dependencies +pnpm install --frozen-lockfile -This project follows the [Conventional Commits][] specification. +# Start development server +node --run dev +``` -Commits should be signed. You can read more about [Commit Signing][] here. +For detailed setup instructions, see [Getting Started](./docs/getting-started.md). -### Commit Message Guidelines +### 2. Make Your Changes -- Commit messages must include a "type" as described on Conventional Commits -- Commit messages **must** start with a capital letter -- Commit messages **must not** end with a period `.` +- **New Pages**: Follow our [page creation guide](./docs/adding-pages.md) +- **Components**: See [creating components](./docs/creating-components.md) +- **Styling**: Follow our [code style guidelines](./docs/code-style.md) -### Pre-commit Hooks +### 3. Test Your Changes -This project uses [Husky][] for Git pre-commit hooks. +```bash +# Format and lint code +node --run format -- Some JSON files are generated during Build time with empty files as placeholders. Build time happens when you run `npx turbo serve` or `npx turbo build`. -- We don't want to commit those unnecessary changes. Since these files exist in the repository, `.gitignore` won't work for them. As a workaround, we have a pre-commit hook to discard those changes. +# Run tests +node --run test -## Pull Request Policy +# Test build +node --run build +``` -This policy governs how contributions should land within this repository. The lines below state the checks and policies to be followed before merging and in the act of merging. +### 4. Submit Your Contribution -### Before merging +1. **Create a branch**: `git checkout -b your-feature-branch` +2. **Commit changes**: Follow our [commit guidelines](./docs/code-style.md#commit-guidelines) +3. **Push to your fork**: `git push origin your-feature-branch` +4. **Open a Pull Request**: Use our [pull request template](.github/PULL_REQUEST_TEMPLATE.md) -We recommend a read on our [Collaborator Guide](COLLABORATOR_GUIDE.md#accepting-modifications) for in-depth details on how we accept contributions into this repository. -The list below describes some merging and approval rules adopted in this repository. +## Documentation Structure -- Pull Requests must be open for at least 48 hours (Or 72 hours if the PR was authored on the weekend). - - Pull requests might be immediately merged if they contain critical bug fixes, short errata (e.g., typos from previous PRs), or any critical change considered a "showstopper" for the website's operation. - - This kind of PRs should only be done by existing collaborators with write-access and/or signed off by administrators/maintainers. - - This rule cannot be used for updates on the `COLLABORATOR_GUIDE.md`, `CONTRIBUTING.md` guide, `CODEOWNERS`, GitHub Actions, or any security-impacting file or document that changes the governing policies of this repository. - - Pull requests might be "fast-tracked", meaning they can be merged before the usual 48 hours' notice if a "fast-track" label is added. - - The person that is fast-tracking the PR (adding the label) must also comment on the PR that they're requesting the PR to be fast-tracked - - The comment must mention `@nodejs/nodejs-website` and must have at least one 👍 (or any other sort of approval reaction) if the person fast-tracking the PR is the author of the PR. - - Fast-tracking is only allowed for small bug fixes, small feature changes, localization changes, or other non-critical/highly-impacting changes not covered by the previous rule that allows PRs to be merged immediately. - - Fast-tracking cannot be used for updates on the `COLLABORATOR_GUIDE.md`, `CONTRIBUTING.md` guide, `CODEOWNERS`, GitHub Actions, or any security-impacting file or document that changes the governing policies of this repository. -- There must be no objections after forty-eight (48) hours (Or seventy-two (72) hours if the PR was authored on the weekend). - - If there are disagreements consensus should be sought. Lack of consensus might require escalation to the Website Team Maintainers. -- At least one approval is required for any PR to be merged. -- Tests must be included in Pull Requests for new features or bug fixes. You are responsible for fixing any test(s) that fail. +Our documentation is organized in the [`docs/`](./docs/) directory, so check out it's **[README](./docs/README.md)** for navigation. -Each contribution is accepted only if there is no objection to it by a collaborator. During the review, collaborators may request that a specific contributor who is an expert in a particular area give an "LGTM" before the PR can be merged. +## Getting Help -If an objection is raised in a pull request by another collaborator, all collaborators involved should try to arrive at a consensus by addressing the concerns through discussion, compromise, or withdrawal of the proposed change(s). +- **Questions?** Start a [Discussion](https://github.com/nodejs/nodejs.org/discussions) +- **Found a bug?** [Open an issue](https://github.com/nodejs/nodejs.org/issues/new/choose) +- **Need clarification?** Comment on existing issues or PRs +- **Want to chat?** Join the Node.js community on [OpenJS Foundation Slack](https://openjs-foundation.slack.com/) -### When merging +## Project Maintainers -- All required Status-checks must have passed. - - **Note:** If you're a collaborator, remember to run the CI checks by labeling the PR with the `github_actions:pull-request` label. -- Please make sure that all discussions are resolved. -- [`squash`][] pull requests made up of multiple commits +This project is maintained by the [Node.js Website Team](https://github.com/nodejs/web-team/blob/main/MEMBERS.md#nodejs-website-team-nodejsnodejs-website). For questions about governance or high-level project direction, you can: -## Developer's Certificate of Origin 1.1 +- Mention `@nodejs/nodejs-website` in issues or PRs +- Contact team members directly for guidance +- Escalate to the [Node.js Technical Steering Committee](https://github.com/nodejs/TSC) if needed -``` -By contributing to this project, I certify that: +## License -- (a) The contribution was created in whole or in part by me and I have the right to - submit it under the open source license indicated in the file; or -- (b) The contribution is based upon previous work that, to the best of my knowledge, - is covered under an appropriate open source license and I have the right under that - license to submit that work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am permitted to submit under a - different license), as indicated in the file; or -- (c) The contribution was provided directly to me by some other person who certified - (a), (b) or (c) and I have not modified it. -- (d) I understand and agree that this project and the contribution are public and that - a record of the contribution (including all personal information I submit with it, - including my sign-off) is maintained indefinitely and may be redistributed consistent - with this project or the open source license(s) involved. +By contributing to this project, you agree that your contributions will be licensed under the project's [MIT License](./LICENSE). -``` +--- -[`squash`]: https://help.github.com/en/articles/about-pull-request-merges#squash-and-merge-your-pull-request-commits -[Conventional Commits]: https://www.conventionalcommits.org/ -[Commit Signing]: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits -[Husky]: https://typicode.github.io/husky/ +**Ready to contribute?** Start with our [Getting Started guide](./docs/getting-started.md) and join the Node.js community in building better web experiences for developers worldwide! 🚀 diff --git a/DEPENDENCY_PINNING.md b/DEPENDENCY_PINNING.md deleted file mode 100644 index cde3f59b9041b..0000000000000 --- a/DEPENDENCY_PINNING.md +++ /dev/null @@ -1,37 +0,0 @@ -## Dependency Pinning - -Based on the initial discussions from [this discussion thread](https://github.com/nodejs/nodejs.org/discussions/5491), we've decided to use a more strict strategy for handling npm dependencies within the Node.js Website. - -The intent here is to prevent the build process, or the website itself, from breaking due to changes in dependencies. As some dependencies do not respect semantic versioning, this is a real concern. Pinning dependencies also ensures that we stay fixed on a specific dependency version. For security updates, Dependabot is still configured to give us security alerts when specific dependencies got security advisories. - -### When adding dependencies - -The following recommendations are in order when adding a new dependency: - -- A dependency should be a `dependencies` if it is part of the build process of the Website or used within runtime code. - - Some non-code dependencies are required for the bootstrap of the repository. They are either used on basic build scripts or Git Hooks. Examples include `husky`, `lint-staged` and others. -- A dependency should be a `devDependencies` if it is not invoked anywhere within the codebase. - - This applies to runtimes, tooling, utility/dev-only commands, type packages and others -- Sometimes, a dependency should be a `devDependency` even if invoked within the codebase, but only used within a development environment or during a test runner. Examples include `jest`, `storybook` and others. -- A dependency should be a `peerDependencies` if it is a runtime dependency of the Website, but the Website itself does not install it. Examples include `react`, `react-dom` and others. - -### When pinning dependencies - -When adding dependencies, you should consider if that dependency should be saved as an exact dependency (`--save-exact`) or use either a `^` or `~` version range operator. The following guidelines are in order: - -- A dependency, in general, should be pinned to its exact dependency if it's either a tooling or a CLI dependency. Examples include `husky`, `prettier`, `jest` and others. -- A dependency should generally use `~` if we're interested in patch updates (such as hot-fixes and bug-fixes) and the package is part of the Development or Testing Environment. (Such as `storybook`, for example) -- A dependency should generally use `^` if they're part of the Website Application itself, such as `react`, `next-intl` etc. This is done because we intentionally want to get these dependencies' latest features and bug-fixes. - - If we're not interested in getting the latest features and bug fixes, we should consider using `~` instead. -- Node. js-only dependencies used in scripts or during the build process of the Website (not used within actual Application code) should use `~` instead. Examples include `glob`, `@nodevu/core` -- TypeScript type packages of corresponding packages should follow the same `semver` of their respective packages - -### About manual updates - -This document intends to outline the strategy we have when adding dependencies. We also recommend that Team members only add new dependencies when explicitly needed. The more dependencies, the harder it gets to review and understand the complexity of the Website Application. You can avoid adding new dependencies if possible. - -Manual updates should be avoided. Dependabot is configured for updating our dependencies. Updating a dependency is only needed if the update breaks the current `semver` constraint. In these situations, good judgement should be used (as a dependency should generally only be updated in these scenarios if the updated version brings new features desired by the team). - -An exception to the manual rule is the version of Node.js defined within `.nvmrc`, and the corresponding `@types/node` dependency. The `@types/node` semver MAJOR version should be kept in sync with the version of Node.js the site is built with. - -Lastly, in general, a Pull Request should not contain `package-lock.json` changes, nor changes to dependencies on `package.json`. diff --git a/GOVERNANCE.md b/GOVERNANCE.md deleted file mode 100644 index 6f6d463030dbc..0000000000000 --- a/GOVERNANCE.md +++ /dev/null @@ -1,54 +0,0 @@ -# Node.js Web Team Governance - -The Node.js Web Team (@nodejs/web) is a team in the Node.js Project that is composed by a set of subteams. Each containing specific responsibilities and goals. - -### TSC Oversight - -Any website change that expresses a position about a global event or group of people requires explicit -[TSC](https://github.com/nodejs/TSC/blob/main/TSC-Charter.md#section-4-responsibilities-of-the-tsc) -approval. This can be obtained by pinging `@nodejs/tsc` and receive no objections after seven days, -or by sending an email to `tsc@iojs.org` and receive at least one approval and no objections after seven days. - -### Node.js Website Team (`@nodejs/nodejs-website`) - -The Node.js Website Team is responsible for the day-to-day technical development of the Node.js Website. This is primarily the development of the website itself, adding new features, pages and components, but also fixing any security issues in the website code, handling operational maintenance, and so on. - -The maintainers on the Node.js Website Team are responsible for steering the technical direction of the Node.js Website, and reserve the right to make final decisions on any issues or pull requests, in line with the Contribution Guidelines, Collaborator Guidelines, the Code of Conduct and the overall Governance premises of the Node.js project. - -Members of this team are nominated through the guidelines provided in the Collaborator Guidelines within this repository. - -### Node.js Web Infra Team (`@nodejs/web-infra`) - -The Node.js Web Infra Team is responsible for maintaining the Infrastructure relating to Node.js's Web Presence. The Node.js Web Infra team has the responsibilities of: - -- Maintaining CI/CD pipelines related to Web Infrastructure -- Maintaining our Infrastructure Providers\* -- Have technical ownership on best-standards and best-practices for our Web Infrastructure (such as Web Frameworks that we use) - -Web Infra Team members should have access to be able to maintain the services mentioned above. - -Members of this team are nominated either by the Node.js Technical Steering Committee (TSC) or the Node.js Build WG and follow the guidelines provided in the Collaborator Guidelines of the Node.js Build WG. Note that members of the Node.js Web Team might also recommend people for nomination. - -\* This team has access to infrastructure providers directly related to the Website only, such as Vercel. Other providers that are shared beyond the Website may be controlled by other teams (for example, the Node.js Build WG owns Cloudflare). - -### Node.js Web Standards Team (`@nodejs/web-standards`) - -The Node.js Web Standards Team is composed of Node.js Collaborators and External Collaborators that have extensive experience or expertisè on Web Standards, such as Ecma262. The Standards Team is responsible for guiding and serving as points of contact when either Node.js Collaborators, the Node.js Technical Steering Committee (TSC), or the Web Team, requires assistance or guidance regarding Web Standards. - -Members of this team are nominated by the Node.js Technical Steering Committee (TSC). Note that members of the Node.js Web Team might also recommend people for nomination. - -### Node.js UX & Design Team (`@nodejs/ux-and-design`) - -The Node.js UX & Design Team is composed of Node.js Collaborators and External Collaborators that have experience or expertisè with UX & Design. The UX & Design Team is responsible for guiding and serving as points of contact when members of the Node.js Web Team require assistance or guidance regarding UX & Design. - -Often members of this team will collaborate on providing best practices and guidelines for the Node.js Website, on matters of UX & Design. Members of this team are also responsible for providing feedback on the Node.js Website, and providing feedback on the Node.js Website's design. (For example, when a discussion arises regarding best practices on topics such as CSS, accessibility, UX flows and intent, or component design, the UX & Design Team has a say on the matter). - -Members of this team are nominated by the Node.js Technical Steering Committee (TSC). Note that members of the Node.js Web Team might also recommend people for nomination. - -## The Interoperability of the Node.js Web Team - -As seen above, the different teams under the Node.js Web Team umbrella are responsible for having the oversight on different aspects of Node.js's Web-related projects. However, it is important to note that the Node.js Web Team is not a set of siloed teams, but rather a set of teams that work together to achieve the same goal: Providing the best Web Experience for Node.js. - -Following this line of thought, the Web Infra Team is responsible for the technical aspects of the Node.js Website (Infrastructure, Framework, CI/CD, etc); The Website Team is responsible for the day-to-day development of the Node.js Website; The UX and Design Team advise on Design Matters and the Web Standards Team advise on best-practices for Web APIs and Web Technologies/Standards. - -But above all, the Web Team should work together to better the Web Experience for Node.js, aiming to provide the best experience for Node.js users. diff --git a/README.md b/README.md index 7d354096d580a..fa8ee3f021916 100644 --- a/README.md +++ b/README.md @@ -30,29 +30,26 @@ nodejs.org scorecard badge - - -

## What is this repo? -[Nodejs.org](https://nodejs.org/) by the [OpenJS Foundation](https://openjsf.org/) is the official website for the Node.jsÂŽ JavaScript runtime. This repo is the source code for the website. It is built using [Next.js](https://nextjs.org), a React Framework. +[Nodejs.org](https://nodejs.org/), maintained by the [OpenJS Foundation](https://openjsf.org/), is the official website for the Node.jsÂŽ JavaScript runtime. This repo is the source code for the website. It is built using [Next.js](https://nextjs.org), a React Framework. ```bash -npm ci -npx turbo dev +pnpm install --frozen-lockfile +pnpm dev -# listening at localhost:3000 +# Listening at http://localhost:3000 ``` ## Contributing This project adopts the Node.js [Code of Conduct][]. -Any person who wants to contribute to the Website is welcome! Please read [Contribution Guidelines][] and see the [Figma Design][] to understand better the structure of this repository. +Anyone who wants to contribute to the website is welcome! Please read [Contribution Guidelines][] and see the [Figma Design][] to understand better the structure of this repository. > \[!IMPORTANT]\ > Please read our [Translation Guidelines][] before contributing to Translation and Localization of the Website @@ -83,11 +80,10 @@ This repository does not contain the codebase or related infrastructure that ser These are maintained in different repositories and we urge users to open **issues in their respective repositories**, for bug reports, feature requests or any matter related to these endpoints. -- [`release-cloudflare-worker`](https://github.com/nodejs/release-cloudflare-worker): The codebase responsible for serving the Node.js Distribution Binaries, API Docs and any other assets from the links mentioned above. +- [`release-cloudflare-worker`](https://github.com/nodejs/release-cloudflare-worker): The codebase responsible for serving the Node.js Distribution Binaries, API docs and any other assets from the links mentioned above. - We use Cloudflare R2 Buckets for storing our Assets and Cloudflare Workers for serving these Assets to the Web. - [`node/doc/api`](https://github.com/nodejs/node/tree/main/doc/api): The source code of our API docs, it contains all the Node.js API Documentation Markdown files - - [`node/doc`](https://github.com/nodejs/node/tree/main/doc) contains the HTML templates, CSS styles and JavaScript code that runs on the client-side of our API Docs generated pages. - - [`node/tools/doc`](https://github.com/nodejs/node/tree/main/tools/doc) contains the tooling that validates, lints, builds and compiles our API Docs. Also responsible for generating what you see when accessing `https://nodejs.org/api/`. +- [`doc-kit`](https://github.com/nodejs/doc-kit): The tooling that builds and compiles our API docs. Also responsible for generating what you see when accessing `https://nodejs.org/api/`. ## Relevant Links @@ -97,6 +93,8 @@ These are maintained in different repositories and we urge users to open **issue [Collaborator Guide][] +[Governance Document][] + [Figma Design][] [Content vs Code][] @@ -105,6 +103,8 @@ These are maintained in different repositories and we urge users to open **issue [Translation Guidelines][] +[Status Page](https://status.nodejs.org/) of the Node.js web infrastructure. + ## Thanks - Thanks to all contributors and collaborators that make this project possible. @@ -115,11 +115,13 @@ These are maintained in different repositories and we urge users to open **issue - Thanks to [Sentry](https://sentry.io/welcome/) for providing an open source license for their error reporting, monitoring and diagnostic tools. - Thanks to [Crowdin](https://crowdin.com/) for providing a platform that allows us to localize the Node.js Website and collaborate with translators. - Thanks to [Orama](https://docs.oramasearch.com/) for providing a search platform that indexes our expansive content and provides lightning-fast results for our users. +- Thanks to [DigitalOcean](https://www.digitalocean.com/) for generously providing Node.js with credits as part of their open source program. [code of conduct]: https://github.com/nodejs/admin/blob/main/CODE_OF_CONDUCT.md [contribution guidelines]: https://github.com/nodejs/nodejs.org/blob/main/CONTRIBUTING.md -[content vs code]: https://github.com/nodejs/nodejs.org/blob/main/CONTENT_VS_CODE.md -[dependency pinning]: https://github.com/nodejs/nodejs.org/blob/main/DEPENDENCY_PINNING.md -[collaborator guide]: https://github.com/nodejs/nodejs.org/blob/main/COLLABORATOR_GUIDE.md -[figma design]: https://www.figma.com/file/pu1vZPqNIM7BePd6W8APA5/Node.js -[translation guidelines]: https://github.com/nodejs/nodejs.org/blob/main/TRANSLATION.md +[content vs code]: https://github.com/nodejs/nodejs.org/blob/main/docs/content-vs-code.md +[dependency pinning]: https://github.com/nodejs/nodejs.org/blob/main/docs/dependency-pinning.md +[collaborator guide]: https://github.com/nodejs/nodejs.org/blob/main/docs/collaborator-guide.md +[governance document]: https://github.com/nodejs/web-team/blob/main/GOVERNANCE.md +[figma design]: https://www.figma.com/file/a10cjjw3MzvRQMPT9FP3xz +[translation guidelines]: https://github.com/nodejs/nodejs.org/blob/main/docs/translation.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000..d6a456a375a0f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security + +## Reporting a vulnerability to Node.js Website + +Please report security issues **privately** using the **GitHub Security Advisory** +workflow ([Security → “Report a vulnerability”](https://github.com/nodejs/nodejs.org/security/advisories/new)). + +Do **not** open a public GitHub issue for security problems. + +We aim to acknowledge reports within **7 business days**. +If you do **not** receive an acknowledgement within **7 business days**, +forward your report to **[tsc@nodejs.org](mailto:tsc@nodejs.org)**. + +## Disclosure & advisories + +Confirmed vulnerabilities will be published as a **GitHub Security Advisory** +(and assigned a CVE when applicable). Notices are also shared via: + +- Node.js blog advisories: [https://nodejs.org/blog/vulnerability/](https://nodejs.org/blog/vulnerability/) + when necessary. diff --git a/TRANSLATION.md b/TRANSLATION.md deleted file mode 100644 index 710edf3281332..0000000000000 --- a/TRANSLATION.md +++ /dev/null @@ -1,89 +0,0 @@ -# Node.js Website Translation Policy - -Node.js is a global platform and so this site has many translations. We use [Crowdin](https://crowdin.com) to translate the Node.js Website - -The site's translation into languages other than English is handled by [Crowdin translators](https://support.crowdin.com/translation-process-overview/). - -We use [`next-intl`](https://next-intl-docs.vercel.app/) as our Internationalization Library. We recommend reading its documentation for API usage. - -## How to translate - -1. Request to join the Node.js Website project on [Crowdin](https://crowdin.com/project/nodejs-web) -2. [Select the language you want to translate](https://support.crowdin.com/joining-translation-project/#starting-translation) -3. [Start translating](https://support.crowdin.com/online-editor/) - -### Any questions or feedbacks on Translations - -If you have any questions or feedbacks on current translations, you can [start a discussion](https://crowdin.com/project/nodejs-web/discussions) by choosing the "New Topic" and your language from the right dropdown, or a [conversation](https://support.crowdin.com/conversations/) by adding your translators. - -## How to add a new language - -Open discussion on the [Crowdin project](https://crowdin.com/project/nodejs-web) to request a new language. After wait for the language to be added to the project by the Crowdin manager - -After that, one of the member of the Node.js Website team should add the new language to the project. - -### Adding a new language to the project - -Go on `/i18n/config.json` and add the new language to the `locales` array. - -Fill the language object with the following fields: - -```json -{ - "code": "fr", - "localName": "Français", - "name": "French", - "langDir": "ltr", - "dateFormat": "DD.MM.YYYY", - "hrefLang": "fr", - "enabled": true -} -``` - -| Field Name | Description | Examples | -| ------------ | ------------------------------------------------------------------------------------------------------ | ------------ | -| `code` | The language code. It must be the same as the folder name | `fr` | -| `localName` | The language name in its own language (it's use in language selector) | `Français` | -| `name` | The language name in English | `French` | -| `langDir` | The direction of the language. `ltr` for left to right, `rtl` for right to left | `ltr` | -| `dateFormat` | The date format. It must be a valid [moment.js format](https://momentjs.com/docs/#/displaying/format/) | `DD.MM.YYYY` | -| `hrefLang` | The language code in [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) format | `fr` | -| `enabled` | If the language is enabled or not | `true` | - -## Adding new Translation Keys - -If you're making a new Component and adding Translation Keys for your Component, they should follow these guidelines: - -- Only add the new translation keys on the `i18n/locales/en.json` file. Crowdin will handle on syncing the files and letting translators know there are new keys to be translated -- The translation keys should have the prefix as the canonical path of your Component. If your Component is `components/Common/MyComponent` the prefix key should be `components.common.myComponent` - - The Translation Key suffix should be easy to understand and semantic. For example, if the key is about "the text of a button that when interacted it copies content to the clipboard", the suffix should probably be `copyButton.title`. The final translation key would be `components.common.myComponent.copyButton.title` - - Translation Keys should be in Camel Case only. - - The values of each Translation Key should follow the [ICU Message Syntax](https://next-intl-docs.vercel.app/docs/usage/messages#rendering-icu-messages) -- All new Translation keys should be added at the bottom of the `i18n/locales/en.json` file. Since this makes it easier for Translators to notice that there are new Translation keys to be translated. - -#### Notes about Translation Keys - -It's important to mention that we use nested translation keys within the Locale files. This means that if your translation key is `components.common.myComponent.something`, you should actually define the key and value within: - -```json -{ - "components": { - ..., - "common": { - ..., - "myComponent": { - "something": "value of translation key" - } - } - } -} -``` - -### Translations and Unit Testing - -Translation Keys should not be translated during Unit Testing. If your Component uses, for example `useTranslations`, you should provide the `` surrounding your `testing-library` render logic, or you can create a wrapper for your test. Note that you should not import the English messages to your Unit Test as: - -- Unit Testing should test a Component functionality. -- Unit Tests should not rely on text, titles, or string bags, as these texts will change arbitrarily and make the test suite fail. - - In this case, you should test your component by aria-text, or other `aria-*` attributes or even by class names or other artifacts. -- Visual Regression Testing is recommended to test how different languages and text appear within a Component. diff --git a/apps/site/.eslintignore b/apps/site/.eslintignore deleted file mode 100644 index 11fbaa70596e8..0000000000000 --- a/apps/site/.eslintignore +++ /dev/null @@ -1,18 +0,0 @@ -# Node.js -node_modules - -# Next.js & Vercel Directories -.next -.turbo -.swc -build - -# We don't want to lint/prettify the Coverage Results -coverage -junit.xml - -# We shouldn't lint statically generated Storybook files -storybook-static - -# This file naturally might break conventional rules -global.d.ts diff --git a/apps/site/.eslintrc.json b/apps/site/.eslintrc.json deleted file mode 100644 index 49124878b6110..0000000000000 --- a/apps/site/.eslintrc.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "extends": "prettier", - "ignorePatterns": ["storybook-static/**"], - "overrides": [ - { - "files": ["public/**/*.js"], - "env": { "browser": true } - }, - { - "files": ["components/**/*.stories.tsx"], - "extends": "plugin:storybook/recommended" - }, - { - "files": ["**/{__tests__,__mocks__}/*.mjs"], - "env": { "jest": true } - }, - { - "files": ["**/*.{js,mjs,ts,tsx,mdx}"], - "env": { "es6": true, "node": true }, - "plugins": ["import"], - "extends": ["eslint:recommended", "next", "next/core-web-vitals"], - "rules": { - "import/namespace": "off", - "import/order": [ - "error", - { - "groups": [ - "builtin", - "external", - "internal", - ["sibling", "parent"], - "index", - "unknown" - ], - "newlines-between": "always", - "alphabetize": { - "order": "asc", - "caseInsensitive": true - } - } - ] - } - }, - { - "files": ["**/*.ts?(x)"], - "plugins": ["@typescript-eslint", "no-relative-import-paths"], - "extends": [ - "plugin:@typescript-eslint/recommended", - "plugin:import/recommended", - "plugin:import/typescript" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": true, - "tsconfigRootDir": "." - }, - "rules": { - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/array-type": ["error", { "default": "generic" }], - "no-relative-import-paths/no-relative-import-paths": [ - "warn", - { "allowSameFolder": true, "prefix": "@" } - ] - } - }, - { - "files": ["**/*.md?(x)"], - "extends": "plugin:mdx/recommended", - "rules": { - "react/jsx-no-undef": "off", - "@next/next/no-img-element": "off" - } - }, - { - "files": ["**/*.{mdx,tsx}"], - "rules": { - "@typescript-eslint/consistent-type-definitions": ["error", "type"], - "react/no-unescaped-entities": "off", - "react/function-component-definition": [ - "error", - { - "namedComponents": "arrow-function", - "unnamedComponents": "arrow-function" - } - ], - "no-restricted-syntax": [ - "error", - { - "selector": "ImportDeclaration[source.value='react'][specifiers.0.type='ImportDefaultSpecifier']", - "message": "Default React import not allowed since we use the TypeScript jsx-transform. If you need a global type that collides with a React named export (such as `MouseEvent`), try using `globalThis.MouseHandler`" - }, - { - "selector": "ImportDeclaration[source.value='react'] :matches(ImportNamespaceSpecifier)", - "message": "Named * React import is not allowed. Please import what you need from React with Named Imports" - } - ] - } - } - ] -} diff --git a/apps/site/.lintstagedrc.json b/apps/site/.lintstagedrc.json new file mode 100644 index 0000000000000..a1bc170c02b46 --- /dev/null +++ b/apps/site/.lintstagedrc.json @@ -0,0 +1,5 @@ +{ + "**/*.{js,mjs,ts,tsx,md,mdx}": ["prettier --check --write", "eslint --fix"], + "**/*.css": ["stylelint --allow-empty-input --fix", "prettier --write"], + "**/*.{json,yml}": ["prettier --check --write"] +} diff --git a/apps/site/.postcssrc.json b/apps/site/.postcssrc.json index f48fb87dce6e0..d750bab9d9e52 100644 --- a/apps/site/.postcssrc.json +++ b/apps/site/.postcssrc.json @@ -1,11 +1,6 @@ { "plugins": { - "postcss-mixins": {}, - "postcss-simple-vars": {}, "postcss-calc": {}, - "postcss-import": {}, - "tailwindcss/nesting": {}, - "tailwindcss": {}, - "autoprefixer": {} + "@tailwindcss/postcss": {} } } diff --git a/apps/site/.remarkrc.json b/apps/site/.remarkrc.json index 40370dea6d613..721eda175ea09 100644 --- a/apps/site/.remarkrc.json +++ b/apps/site/.remarkrc.json @@ -1,21 +1,13 @@ { - "settings": { - "bullet": "-", - "resourceLink": true - }, "plugins": [ "remark-frontmatter", - "remark-preset-lint-node", - ["remark-gfm", false], - ["remark-lint-fenced-code-flag", false], - ["remark-lint-first-heading-level", false], - ["remark-lint-maximum-line-length", false], - ["remark-lint-no-file-name-articles", false], - ["remark-lint-no-literal-urls", false], - ["remark-lint-no-unused-definitions", false], - ["remark-lint-no-undefined-references", false], - ["remark-lint-prohibited-strings", false], - ["remark-lint-unordered-list-marker-style", "-"], - ["remark-preset-lint-node/remark-lint-nodejs-links.js", false] + "@node-core/remark-lint", + [ + "@node-core/remark-lint/rules/validate-links", + { + "ignoreFiles": ["pages/!(en)/**/*.{md,mdx}"], + "ignoreLinks": ["/learn", "/learn/**/*", "/static/**/*", "/feed/*"] + } + ] ] } diff --git a/apps/site/.storybook/main.ts b/apps/site/.storybook/main.ts deleted file mode 100644 index 4247bcec94030..0000000000000 --- a/apps/site/.storybook/main.ts +++ /dev/null @@ -1,60 +0,0 @@ -import classNames from 'classnames'; -import type { StorybookConfig } from '@storybook/nextjs'; - -const rootClasses = classNames( - // note: this is hard-coded sadly as next/font can only be loaded within next.js context - '__variable_open-sans-normal', - // note: this is hard-coded sadly as next/font can only be loaded within next.js context - '__variable_ibm-plex-mono-normal' -); - -const config: StorybookConfig = { - stories: ['../components/**/*.stories.tsx'], - logLevel: 'error', - staticDirs: ['../public'], - typescript: { reactDocgen: false, check: false }, - core: { disableTelemetry: true, disableWhatsNewNotifications: true }, - framework: { - name: '@storybook/nextjs', - options: { builder: { useSWC: true } }, - }, - previewBody: - // This `' + - // This adds the base styling for dark/light themes within Storybook. This is a Storybook-only style - ``, - addons: [ - '@storybook/addon-controls', - '@storybook/addon-interactions', - '@storybook/addon-themes', - '@storybook/addon-viewport', - ], - webpack: async config => ({ - ...config, - // We want to conform as much as possible with our target settings - target: 'browserslist', - // Performance Hints do not make sense on Storybook as it is bloated by design - performance: { hints: false }, - // `nodevu` is a Node.js-specific package that requires Node.js modules - // this is incompatible with Storybook. So we just mock the module - resolve: { ...config.resolve, alias: { '@nodevu/core': false } }, - // We need to configure `node:` APIs as Externals to WebPack - // since essentially they're not supported on the browser - externals: { - 'node:fs': 'commonjs fs', - 'node:url': 'commonjs url', - 'node:path': 'commonjs path', - 'node:readline': 'commonjs readline', - }, - // Removes Pesky Critical Dependency Warnings due to `next/font` - ignoreWarnings: [ - e => - e.message.includes('Critical dep') || - e.message.includes('was not found in'), - ], - }), -}; - -export default config; diff --git a/apps/site/.storybook/preview.tsx b/apps/site/.storybook/preview.tsx deleted file mode 100644 index 9ede2e3a49e1a..0000000000000 --- a/apps/site/.storybook/preview.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { NextIntlClientProvider } from 'next-intl'; - -import { withThemeByDataAttribute } from '@storybook/addon-themes'; -import { NotificationProvider } from '@/providers/notificationProvider'; -import { STORYBOOK_MODES, STORYBOOK_SIZES } from '@/.storybook/constants'; -import type { Preview, ReactRenderer } from '@storybook/react'; - -import englishLocale from '@/i18n/locales/en.json'; - -import '../next.fonts'; -import '../styles/index.css'; - -const preview: Preview = { - parameters: { - nextjs: { router: { basePath: '' }, appDirectory: true }, - chromatic: { modes: STORYBOOK_MODES }, - viewport: { defaultViewport: 'large', viewports: STORYBOOK_SIZES }, - }, - decorators: [ - Story => ( - - - - - - ), - withThemeByDataAttribute({ - themes: { light: '', dark: 'dark' }, - defaultTheme: 'light', - attributeName: 'data-theme', - }), - ], -}; - -export default preview; diff --git a/apps/site/.stylelintignore b/apps/site/.stylelintignore index cb71469e033ce..bb4f97f4cd44d 100644 --- a/apps/site/.stylelintignore +++ b/apps/site/.stylelintignore @@ -7,11 +7,17 @@ build # Public Folder public -# Jest -coverage - -# Storybook -storybook-static +# Test Runner +junit.xml +lcov.info # Old Styles styles/old + +# Cloudflare Build Output +.open-next +.wrangler + +# Playwright +test-results +playwright-report diff --git a/apps/site/.stylelintrc.mjs b/apps/site/.stylelintrc.mjs index 3fea6d8b268c8..cbb76b9e67ca4 100644 --- a/apps/site/.stylelintrc.mjs +++ b/apps/site/.stylelintrc.mjs @@ -4,12 +4,14 @@ const CUSTOM_AT_RULES = [ 'apply', 'layer', 'responsive', + 'reference', + 'utility', + 'theme', + 'custom-variant', 'screen', + 'source', 'tailwind', 'variants', - // PostCSS-specific at-rules - 'define-mixin', - 'mixin', ]; // Enforces certain selectors to be only in camelCase notation @@ -21,7 +23,11 @@ const ONLY_ALLOW_CAMEL_CASE_SELECTORS = [ export default { extends: ['stylelint-config-standard'], - plugins: ['stylelint-order', 'stylelint-selector-bem-pattern'], + plugins: [ + 'stylelint-order', + 'stylelint-selector-bem-pattern', + '@node-core/ui-components/src/stylelint/one-utility-class-per-line.mjs', + ], rules: { // Enforces Element Class Names to be camelCase 'selector-class-pattern': ONLY_ALLOW_CAMEL_CASE_SELECTORS, @@ -41,5 +47,8 @@ export default { 'media-feature-range-notation': 'prefix', // Adopts the import notation from `postcss-import` 'import-notation': 'string', + // Allow the `@apply` at rule as its part of Tailwind + 'at-rule-no-deprecated': [true, { ignoreAtRules: CUSTOM_AT_RULES }], + 'nodejs/one-utility-class-per-line': true, }, }; diff --git a/apps/site/README.md b/apps/site/README.md deleted file mode 100644 index 49dcc7a74dcae..0000000000000 --- a/apps/site/README.md +++ /dev/null @@ -1 +0,0 @@ -# Node.js website diff --git a/apps/site/app/[locale]/[...path]/page.tsx b/apps/site/app/[locale]/[...path]/page.tsx new file mode 100644 index 0000000000000..aecb38f3da63f --- /dev/null +++ b/apps/site/app/[locale]/[...path]/page.tsx @@ -0,0 +1,97 @@ +/** + * This file extends on the `page.tsx` file, which is the default file that is used to render + * the entry points for each locale and then also reused within the [...path] route to render the + * and contains all logic for rendering our dynamic and static routes within the Node.js Website. + * + * Note: that each `page.tsx` should have its own `generateStaticParams` to prevent clash of + * dynamic params, which will lead on static export errors and other sort of issues. + */ + +import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; +import { notFound } from 'next/navigation'; + +import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; +import { ENABLE_STATIC_EXPORT_LOCALE } from '#site/next.constants.mjs'; +import { dynamicRouter } from '#site/next.dynamic.mjs'; +import * as basePage from '#site/next.dynamic.page.mjs'; + +import type { DynamicParams } from '#site/types'; +import type { FC } from 'react'; + +type PageParams = DynamicParams<{ path: Array }>; + +// This is the default Viewport Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function +export const generateViewport = basePage.generateViewport; + +// This generates each page's HTML Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata +export const generateMetadata = basePage.generateMetadata; + +// Generates all possible static paths based on the locales and environment configuration +// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) +// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales +// - Otherwise, generates paths only for the default locale +// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params +export const generateStaticParams = async () => { + // Return an empty array if static export is disabled + if (!ENABLE_STATIC_EXPORT) { + return []; + } + + const routes = await dynamicRouter.getAllRoutes(); + + // Helper function to fetch and map routes for a specific locale + const getRoutesForLocale = async (l: string) => + routes.map(pathname => dynamicRouter.mapPathToRoute(l, pathname)); + + // Determine which locales to include in the static export + const locales = ENABLE_STATIC_EXPORT_LOCALE + ? availableLocaleCodes + : [defaultLocale.code]; + + // Generates all possible routes for all available locales + const routesWithLocales = await Promise.all(locales.map(getRoutesForLocale)); + + return routesWithLocales.flat().sort(); +}; + +// This method parses the current pathname and does any sort of modifications needed on the route +// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component +// finally it returns (if the locale and route are valid) the React Component with the relevant context +// and attached context providers for rendering the current page +const getPage: FC = async props => { + const { path, locale: routeLocale } = await props.params; + + // Gets the current full pathname for a given path + const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); + + // Gets the Markdown content and context + const [content, context] = await basePage.getMarkdownContext({ + locale, + pathname, + }); + + // If we have a filename and layout then we have a page + if (context.filename && context.frontmatter.layout) { + return basePage.renderPage({ + content, + layout: context.frontmatter.layout, + context, + }); + } + + return notFound(); +}; + +// Enforces that this route is used as static rendering +// Except whenever on the Development mode as we want instant-refresh when making changes +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = 300; + +export default getPage; diff --git a/apps/site/app/[locale]/[[...path]]/page.tsx b/apps/site/app/[locale]/[[...path]]/page.tsx deleted file mode 100644 index 14aff4e2d41b4..0000000000000 --- a/apps/site/app/[locale]/[[...path]]/page.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { setContext, setTags } from '@sentry/nextjs'; -import { notFound, redirect } from 'next/navigation'; -import { unstable_setRequestLocale } from 'next-intl/server'; -import type { FC } from 'react'; - -import { setClientContext } from '@/client-context'; -import { MDXRenderer } from '@/components/mdxRenderer'; -import WithLayout from '@/components/withLayout'; -import { ENABLE_STATIC_EXPORT, VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { PAGE_VIEWPORT, DYNAMIC_ROUTES } from '@/next.dynamic.constants.mjs'; -import { dynamicRouter } from '@/next.dynamic.mjs'; -import { - allLocaleCodes, - availableLocaleCodes, - defaultLocale, -} from '@/next.locales.mjs'; -import { MatterProvider } from '@/providers/matterProvider'; - -type DynamicStaticPaths = { path: Array; locale: string }; -type DynamicParams = { params: DynamicStaticPaths }; - -// This is the default Viewport Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function -export const generateViewport = async () => ({ ...PAGE_VIEWPORT }); - -// This generates each page's HTML Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata -export const generateMetadata = async ({ params }: DynamicParams) => { - const { path = [], locale = defaultLocale.code } = params; - - const pathname = dynamicRouter.getPathname(path); - - return dynamicRouter.getPageMetadata(locale, pathname); -}; - -// Gets all mapped routes to the Next.js Routing Engine by Locale -const mapRoutesForLocale = async (locale: string) => { - const routesForLanguage = await dynamicRouter.getRoutesByLanguage(locale); - - return routesForLanguage.map(pathname => - dynamicRouter.mapPathToRoute(locale, pathname) - ); -}; - -// This provides all the possible paths that can be generated statically -// + provides all the paths that we support on the Node.js Website -export const generateStaticParams = async () => { - const paths: Array = []; - - // If static exports are enabled we need to compute all available routes - // And then append them to Next.js's Route Engine - if (ENABLE_STATIC_EXPORT) { - const allAvailableRoutes = await Promise.all( - availableLocaleCodes.map(mapRoutesForLocale) - ); - - paths.push(...allAvailableRoutes.flat()); - } - - return paths.sort(); -}; - -// This method parses the current pathname and does any sort of modifications needed on the route -// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component -// finally it returns (if the locale and route are valid) the React Component with the relevant context -// and attached context providers for rendering the current page -const getPage: FC = async ({ params }) => { - const { path = [], locale = defaultLocale.code } = params; - - if (!availableLocaleCodes.includes(locale)) { - // Forces the current locale to be the Default Locale - unstable_setRequestLocale(defaultLocale.code); - - if (!allLocaleCodes.includes(locale)) { - // when the locale is not listed in the locales, return NotFound - return notFound(); - } - - // Redirect to the default locale path - const pathname = dynamicRouter.getPathname(path); - return redirect(`/${defaultLocale.code}/${pathname}`); - } - - // Configures the current Locale to be the given Locale of the Request - unstable_setRequestLocale(locale); - - // Gets the current full pathname for a given path - const pathname = dynamicRouter.getPathname(path); - - const staticGeneratedLayout = DYNAMIC_ROUTES.get(pathname); - - // If the current pathname is a statically generated route - // it means it does not have a Markdown file nor exists under the filesystem - // but it is a valid route with an assigned layout that should be rendered - if (staticGeneratedLayout !== undefined) { - // Decorate the Locale and current Pathname to Sentry - setTags({ pathname, locale }); - - // Metadata and shared Context to be available through the lifecycle of the page - const sharedContext = { pathname: `/${pathname}` }; - - // Defines a shared Server Context for the Client-Side - // That is shared for all pages under the dynamic router - setClientContext(sharedContext); - - // The Matter Provider allows Client-Side injection of the data - // to a shared React Client Provider even though the page is rendered - // within a server-side context - return ( - - - - ); - } - - // We retrieve the source of the Markdown file by doing an educated guess - // of what possible files could be the source of the page, since the extension - // context is lost from `getStaticProps` as a limitation of Next.js itself - const { source, filename } = await dynamicRouter.getMarkdownFile( - locale, - pathname - ); - - // Decorate the Locale and current Pathname to Sentry - setTags({ pathname, locale, filename }); - - if (source.length && filename.length) { - // This parses the source Markdown content and returns a React Component and - // relevant context from the Markdown File - const { MDXContent, frontmatter, headings, readingTime } = - await dynamicRouter.getMDXContent(source, filename); - - // Metadata and shared Context to be available through the lifecycle of the page - const sharedContext = { - frontmatter, - headings, - pathname: `/${pathname}`, - readingTime, - filename, - }; - - // Add Additional relevant reproduction Context from MDX - setContext('MDX Provider', { - frontmatter, - headings, - readingTime, - }); - - // Defines a shared Server Context for the Client-Side - // That is shared for all pages under the dynamic router - setClientContext(sharedContext); - - // The Matter Provider allows Client-Side injection of the data - // to a shared React Client Provider even though the page is rendered - // within a server-side context - return ( - - - - - - ); - } - - return notFound(); -}; - -// In this case we want to catch-all possible pages even to this page. This ensures that we use our 404 -// and that all pages including existing ones are handled here and provide `next-intl` locale also -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = true; - -// Enforces that this route is used as static rendering -// Except whenever on the Development mode as we want instant-refresh when making changes -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'force-static'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; - -export default getPage; diff --git a/apps/site/app/[locale]/blog/[...path]/page.tsx b/apps/site/app/[locale]/blog/[...path]/page.tsx new file mode 100644 index 0000000000000..2a11e65becc46 --- /dev/null +++ b/apps/site/app/[locale]/blog/[...path]/page.tsx @@ -0,0 +1,82 @@ +import { defaultLocale } from '@node-core/website-i18n'; +import { notFound } from 'next/navigation'; + +import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; +import { BLOG_DYNAMIC_ROUTES } from '#site/next.dynamic.constants.mjs'; +import * as basePage from '#site/next.dynamic.page.mjs'; + +import type { DynamicParams } from '#site/types'; +import type { FC } from 'react'; + +type PageParams = DynamicParams<{ path: Array }>; + +// This is the default Viewport Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function +export const generateViewport = basePage.generateViewport; + +// This generates each page's HTML Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata +export const generateMetadata = ({ params }: PageParams) => + basePage.generateMetadata({ params, prefix: 'blog' }); + +// Generates all possible static paths based on the locales and environment configuration +// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) +// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales +// - Otherwise, generates paths only for the default locale +// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params +export const generateStaticParams = async () => { + // Return an empty array if static export is disabled + if (!ENABLE_STATIC_EXPORT) { + return []; + } + + return BLOG_DYNAMIC_ROUTES.map(pathname => ({ + locale: defaultLocale.code, + path: pathname.split('/'), + })); +}; + +// This method parses the current pathname and does any sort of modifications needed on the route +// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component +// finally it returns (if the locale and route are valid) the React Component with the relevant context +// and attached context providers for rendering the current page +const getPage: FC = async props => { + const { path, locale: routeLocale } = await props.params; + + // Gets the current full pathname for a given path + const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); + + // Verifies if the current route is a dynamic route + const isDynamicRoute = BLOG_DYNAMIC_ROUTES.some(r => r.includes(pathname)); + + // Gets the Markdown content and context for Blog pages + // otherwise this is likely a blog-category or a blog post + const [content, context] = await basePage.getMarkdownContext({ + locale, + pathname: `blog/${pathname}`, + }); + + // If this isn't a valid dynamic route for blog post or there's no markdown file + // for this, then we fail as not found as there's nothing we can do. + if (isDynamicRoute || context.filename) { + return basePage.renderPage({ + content, + layout: context.frontmatter.layout ?? 'blog-category', + context: { ...context, pathname: `/blog/${pathname}` }, + }); + } + + return notFound(); +}; + +// Enforces that this route is used as static rendering +// Except whenever on the Development mode as we want instant-refresh when making changes +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = 300; + +export default getPage; diff --git a/apps/site/app/[locale]/download/archive/[version]/page.tsx b/apps/site/app/[locale]/download/archive/[version]/page.tsx new file mode 100644 index 0000000000000..1cc23837ccdf1 --- /dev/null +++ b/apps/site/app/[locale]/download/archive/[version]/page.tsx @@ -0,0 +1,93 @@ +import { defaultLocale } from '@node-core/website-i18n'; +import { notFound, redirect } from 'next/navigation'; + +import provideReleaseData from '#site/next-data/providers/releaseData'; +import provideReleaseVersions from '#site/next-data/providers/releaseVersions'; +import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; +import * as basePage from '#site/next.dynamic.page.mjs'; + +import type { DynamicParams } from '#site/types'; +import type { FC } from 'react'; + +type PageParams = DynamicParams<{ version: string }>; + +// This is the default Viewport Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function +export const generateViewport = basePage.generateViewport; + +// This generates each page's HTML Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata +export const generateMetadata = basePage.generateMetadata; + +// Generates all possible static paths based on the locales and environment configuration +// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) +// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales +// - Otherwise, generates paths only for the default locale +// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params +export const generateStaticParams = async () => { + // Return an empty array if static export is disabled + if (!ENABLE_STATIC_EXPORT) { + return []; + } + + const versions = await provideReleaseVersions(); + + return versions.map(version => ({ + locale: defaultLocale.code, + version, + })); +}; + +// This method parses the current pathname and does any sort of modifications needed on the route +// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component +// finally it returns (if the locale and route are valid) the React Component with the relevant context +// and attached context providers for rendering the current page +const getPage: FC = async props => { + const { version, locale: routeLocale } = await props.params; + + // Gets the current full pathname for a given path + const [locale, pathname] = basePage.getLocaleAndPath(version, routeLocale); + + if (version === 'current') { + const releaseData = await provideReleaseData(); + + const release = releaseData.find(release => release.status === 'Current'); + + redirect(`/${locale}/download/archive/${release?.versionWithPrefix}`); + } + + const versions = await provideReleaseVersions(); + + // Verifies if the current route is a dynamic route + const isDynamicRoute = versions.some(r => r.includes(pathname)); + + // Gets the Markdown content and context for Download Archive pages + const [content, context] = await basePage.getMarkdownContext({ + locale, + pathname: 'download/archive', + }); + + // If this isn't a valid dynamic route for archive version or there's no markdown + // file for this, then we fail as not found as there's nothing we can do. + if (isDynamicRoute && context.filename) { + return basePage.renderPage({ + content, + layout: context.frontmatter.layout!, + context: { ...context, pathname: `/download/archive/${pathname}` }, + }); + } + + return notFound(); +}; + +// Enforces that this route is used as static rendering +// Except whenever on the Development mode as we want instant-refresh when making changes +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = 300; + +export default getPage; diff --git a/apps/site/app/[locale]/error.tsx b/apps/site/app/[locale]/error.tsx index 8f642ae363cc6..0c4bf7f827ec8 100644 --- a/apps/site/app/[locale]/error.tsx +++ b/apps/site/app/[locale]/error.tsx @@ -1,36 +1,29 @@ 'use client'; -import { ArrowRightIcon } from '@heroicons/react/24/solid'; -import { captureException } from '@sentry/nextjs'; import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; -import Button from '@/components/Common/Button'; -import GlowingBackdrop from '@/components/Common/GlowingBackdrop'; -import CenteredLayout from '@/layouts/Centered'; +import Button from '#site/components/Common/Button'; +import GlowingBackdropLayout from '#site/layouts/GlowingBackdrop'; + +import type { FC } from 'react'; -const ErrorPage: FC<{ error: Error }> = ({ error }) => { - captureException(error); +const ErrorPage: FC<{ error: Error }> = () => { const t = useTranslations(); return ( - - - -
- 500 -

- {t('layouts.error.internalServerError.title')} -

-

- {t('layouts.error.internalServerError.description')} -

- -
-
+ + 500 + +

+ {t('layouts.error.internalServerError.title')} +

+ +

+ {t('layouts.error.internalServerError.description')} +

+ + +
); }; diff --git a/apps/site/app/[locale]/feed/[feed]/route.ts b/apps/site/app/[locale]/feed/[feed]/route.ts index a77e9d9ccd3b4..c70c0b63f9b3d 100644 --- a/apps/site/app/[locale]/feed/[feed]/route.ts +++ b/apps/site/app/[locale]/feed/[feed]/route.ts @@ -1,25 +1,24 @@ +import { defaultLocale } from '@node-core/website-i18n'; import { NextResponse } from 'next/server'; -import provideWebsiteFeeds from '@/next-data/providers/websiteFeeds'; -import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { siteConfig } from '@/next.json.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; +import { siteConfig } from '#site/next.json.mjs'; +import { getFeeds } from '#site/util/feeds'; -// We only support fetching these pages from the /en/ locale code -const locale = defaultLocale.code; - -type StaticParams = { params: { feed: string; locale: string } }; +type DynamicStaticPaths = { locale: string; feed: string }; +type StaticParams = { params: Promise }; // This is the Route Handler for the `GET` method which handles the request // for the Node.js Website Blog Feeds (RSS) // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async (_: Request, { params }: StaticParams) => { +export const GET = async (_: Request, props: StaticParams) => { + const params = await props.params; + // Generate the Feed for the given feed type (blog, releases, etc) - const websiteFeed = provideWebsiteFeeds(params.feed); + const websiteFeed = getFeeds(params.feed); return new NextResponse(websiteFeed, { headers: { 'Content-Type': 'application/xml' }, - status: websiteFeed ? 200 : 404, + status: websiteFeed !== undefined ? 200 : 404, }); }; @@ -27,13 +26,14 @@ export const GET = async (_: Request, { params }: StaticParams) => { // `[locale]/feeds/[feed]` and returns an array of all available static paths // This is used for ISR static validation and generation export const generateStaticParams = async () => - siteConfig.rssFeeds.map(feed => ({ feed: feed.file, locale })); + siteConfig.rssFeeds.map(feed => ({ + locale: defaultLocale.code, + feed: feed.file, + })); -// In this case we want to catch-all possible requests. This is so that if a non defined feed is -// requested we can manually return a 404 response for it instead of having Next.js handle it -// and return our top level custom 404 html page instead +// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = true; +export const dynamicParams = false; // Enforces that this route is cached and static as much as possible // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic @@ -42,4 +42,4 @@ export const dynamic = 'force-static'; // Ensures that this endpoint is invalidated and re-executed every X minutes // so that when new deployments happen, the data is refreshed // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; +export const revalidate = 300; diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 87d0f28dd1801..7b905a0fd03a3 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -1,33 +1,41 @@ -import { Analytics } from '@vercel/analytics/react'; -import { SpeedInsights } from '@vercel/speed-insights/next'; +import PlatformAnalytics from '#platform/analytics'; +import { availableLocales, defaultLocale } from '@node-core/website-i18n'; import classNames from 'classnames'; -import { getLocale } from 'next-intl/server'; -import type { FC, PropsWithChildren } from 'react'; +import { NextIntlClientProvider } from 'next-intl'; + +import BaseLayout from '#site/layouts/Base'; +import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { ThemeProvider } from '#site/providers/themeProvider'; -import BaseLayout from '@/layouts/Base'; -import { VERCEL_ENV } from '@/next.constants.mjs'; -import { IBM_PLEX_MONO, OPEN_SANS } from '@/next.fonts'; -import { availableLocalesMap, defaultLocale } from '@/next.locales.mjs'; -import { LocaleProvider } from '@/providers/localeProvider'; -import { ThemeProvider } from '@/providers/themeProvider'; +import type { FC, PropsWithChildren } from 'react'; -import '@/styles/index.css'; +import '#site/styles/index.css'; const fontClasses = classNames(IBM_PLEX_MONO.variable, OPEN_SANS.variable); -const RootLayout: FC = async ({ children }) => { - const locale = await getLocale(); +type RootLayoutProps = PropsWithChildren<{ + params: Promise<{ locale: string }>; +}>; + +const RootLayout: FC = async ({ children, params }) => { + const { locale } = await params; - const { langDir, hrefLang } = availableLocalesMap[locale] || defaultLocale; + const { langDir, hrefLang } = + availableLocales.find(l => l.code === locale) || defaultLocale; return ( - + - + {children} - + = async ({ children }) => { href="https://social.lfx.dev/@nodejs" /> - {VERCEL_ENV && ( - <> - - - - )} + ); diff --git a/apps/site/app/[locale]/next-data/api-data/route.ts b/apps/site/app/[locale]/next-data/api-data/route.ts deleted file mode 100644 index 880c596144636..0000000000000 --- a/apps/site/app/[locale]/next-data/api-data/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { deflateSync } from 'node:zlib'; - -import provideReleaseData from '@/next-data/providers/releaseData'; -import { GITHUB_API_KEY, VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; -import type { GitHubApiFile } from '@/types'; -import { getGitHubApiDocsUrl } from '@/util/gitHubUtils'; -import { parseRichTextIntoPlainText } from '@/util/stringUtils'; - -// Defines if we should use the GitHub API Key for the request -// based on the environment variable `GITHUB_API_KEY` -const authorizationHeaders = GITHUB_API_KEY - ? { headers: { Authorization: `Bearer ${GITHUB_API_KEY}` } } - : undefined; - -// Formats a pathname for an API file from Markdown file basename -const getPathnameForApiFile = (name: string, version: string) => - `docs/${version}/api/${name.replace('.md', '.html')}`; - -// This is the Route Handler for the `GET` method which handles the request -// for a digest and metadata of all API pages from the Node.js Website -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async () => { - const releases = provideReleaseData(); - - const { versionWithPrefix } = releases.find( - release => release.status === 'LTS' - )!; - - const gitHubApiResponse = await fetch( - getGitHubApiDocsUrl(versionWithPrefix), - authorizationHeaders - ); - - return gitHubApiResponse.json().then((apiDocsFiles: Array) => { - // maps over each api file and get the download_url, fetch the content and deflates it - const mappedApiFiles = apiDocsFiles.map( - async ({ name, path: filename, download_url }) => { - const apiFileResponse = await fetch(download_url); - - // Retrieves the content as a raw text string - const source = await apiFileResponse.text(); - - // Removes empty/blank lines or lines just with spaces and trims each line - // from leading and trailing paddings/spaces - const cleanedContent = parseRichTextIntoPlainText(source); - - const deflatedSource = deflateSync(cleanedContent).toString('base64'); - - return { - filename, - pathname: getPathnameForApiFile(name, versionWithPrefix), - content: deflatedSource, - }; - } - ); - - return Promise.all(mappedApiFiles).then(Response.json); - }); -}; - -// This function generates the static paths that come from the dynamic segments -// `[locale]/next-data/api-data/` and returns an array of all available static paths -// This is used for ISR static validation and generation -export const generateStaticParams = async () => [ - { locale: defaultLocale.code }, -]; - -// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = false; - -// Enforces that this route is used as static rendering -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/next-data/blog-data/[category]/[page]/route.ts b/apps/site/app/[locale]/next-data/blog-data/[category]/[page]/route.ts deleted file mode 100644 index e00c2305a10c8..0000000000000 --- a/apps/site/app/[locale]/next-data/blog-data/[category]/[page]/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - provideBlogCategories, - provideBlogPosts, - providePaginatedBlogPosts, -} from '@/next-data/providers/blogData'; -import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; - -type StaticParams = { - params: { locale: string; category: string; page: string }; -}; - -// This is the Route Handler for the `GET` method which handles the request -// for providing Blog Posts for Blog Categories and Pagination Metadata -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async (_: Request, { params }: StaticParams) => { - const requestedPage = Number(params.page); - - const data = - requestedPage >= 1 - ? // This allows us to blindly get all blog posts from a given category - // if the page number is 0 or something smaller than 1 - providePaginatedBlogPosts(params.category, requestedPage) - : provideBlogPosts(params.category); - - return Response.json(data, { status: data.posts.length ? 200 : 404 }); -}; - -// This function generates the static paths that come from the dynamic segments -// `[locale]/next-data/blog-data/[category]` and returns an array of all available static paths -// This is used for ISR static validation and generation -export const generateStaticParams = async () => { - // This metadata is the original list of all available categories and all available years - // within the Node.js Website Blog Posts (2011, 2012...) - const categories = provideBlogCategories(); - - const mappedCategories = categories.map(category => { - // gets the current pagination meta for a given category - const { pagination } = provideBlogPosts(category); - - // creates a sequential array containing each page number - const pages = [...Array(pagination.pages).keys()].map((_, key) => key + 1); - - // maps the data into valid Next.js Route Engine routes with all required params - // notice that we add an extra 0 in the beginning in case we want a non-paginated route - return [0, ...pages].map(page => ({ - locale: defaultLocale.code, - page: String(page), - category, - })); - }); - - return mappedCategories.flat(); -}; - -// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = false; - -// Enforces that this route is cached and static as much as possible -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/next-data/changelog-data/[version]/route.ts b/apps/site/app/[locale]/next-data/changelog-data/[version]/route.ts deleted file mode 100644 index 9184ee68af578..0000000000000 --- a/apps/site/app/[locale]/next-data/changelog-data/[version]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { provideChangelogData } from '@/next-data/providers/changelogData'; -import provideReleaseData from '@/next-data/providers/releaseData'; -import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; - -type StaticParams = { - params: { version: string }; -}; - -// This is the Route Handler for the `GET` method which handles the request -// for generating static data related to the Node.js Changelog Data -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async (_: Request, { params }: StaticParams) => { - const changelogData = await provideChangelogData(params.version); - - return Response.json(changelogData); -}; - -// This function generates the static paths that come from the dynamic segments -// `[locale]/next-data/changelog-data/[version]` and returns an array of all available static paths -// This is used for ISR static validation and generation -export const generateStaticParams = async () => { - const releases = provideReleaseData(); - - const mappedParams = releases.map(release => ({ - locale: defaultLocale.code, - version: String(release.versionWithPrefix), - })); - - return mappedParams; -}; - -// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = false; - -// Enforces that this route is used as static rendering -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/next-data/og/[category]/[title]/route.tsx b/apps/site/app/[locale]/next-data/og/[category]/[title]/route.tsx new file mode 100644 index 0000000000000..f99b8d756b819 --- /dev/null +++ b/apps/site/app/[locale]/next-data/og/[category]/[title]/route.tsx @@ -0,0 +1,71 @@ +import HexagonGrid from '@node-core/ui-components/Icons/HexagonGrid'; +import JsWhiteIcon from '@node-core/ui-components/Icons/Logos/JsWhite'; +import { defaultLocale } from '@node-core/website-i18n'; +import { ImageResponse } from 'next/og'; + +import { DEFAULT_CATEGORY_OG_TYPE } from '#site/next.constants.mjs'; + +// TODO: use CSS variables instead of absolute values +const CATEGORY_TO_THEME_COLOUR_MAP = { + announcement: 'rgb(26, 63, 29)', + release: 'rgb(12, 123, 179)', + vulnerability: 'rgb(174, 95, 0)', +}; + +type StaticParams = { + params: Promise<{ + locale: string; + category: keyof typeof CATEGORY_TO_THEME_COLOUR_MAP; + title: string; + }>; +}; + +// This is the Route Handler for the `GET` method which handles the request +// for generating OpenGraph images for Blog Posts and Pages +// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers +export const GET = async (_: Request, props: StaticParams) => { + const params = await props.params; + + const categoryColour = + params.category in CATEGORY_TO_THEME_COLOUR_MAP + ? CATEGORY_TO_THEME_COLOUR_MAP[params.category] + : CATEGORY_TO_THEME_COLOUR_MAP[DEFAULT_CATEGORY_OG_TYPE]; + + const gridBackground = `radial-gradient(circle, ${categoryColour}, transparent)`; + + return new ImageResponse( +
+ + +
+ + +

{params.title.slice(0, 100)}

+
+
, + { width: 1200, height: 600 } + ); +}; + +// This function generates the static paths that come from the dynamic segments +// `[locale]/next-data/og/[category]/[title]` this will return a default value as we don't want to +// statically generate this route as it is compute-expensive. +// Hence we generate a "fake" OG image during build just to satisfy Next.js requirements. +export const generateStaticParams = async () => [ + { + locale: defaultLocale.code, + category: 'announcement', + title: 'Run JavaScript Everywhere', + }, +]; + +// Enforces that this route is cached and static as much as possible +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; diff --git a/apps/site/app/[locale]/next-data/og/route.tsx b/apps/site/app/[locale]/next-data/og/route.tsx deleted file mode 100644 index 481826099722c..0000000000000 --- a/apps/site/app/[locale]/next-data/og/route.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { ImageResponse } from 'next/og'; - -import HexagonGrid from '@/components/Icons/HexagonGrid'; -import JsIconWhite from '@/components/Icons/Logos/JsIconWhite'; -import { - ENABLE_STATIC_EXPORT, - VERCEL_ENV, - VERCEL_REVALIDATE, -} from '@/next.constants.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; -import tailwindConfig from '@/tailwind.config'; -import { hexToRGBA } from '@/util/hexToRGBA'; - -// This is the default type of blog post type that we use for OG -const defaultTypeParam = 'announcement'; - -// This is the Route Handler for the `GET` method which handles the request -// for generating OpenGrapgh images for Blog Posts and Pages -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async (request: Request) => { - const { searchParams } = new URL(request.url); - - // ?title= - const hasTitle = searchParams.has('title'); - const title = hasTitle ? searchParams.get('title')?.slice(0, 100) : undefined; - - // ?type=<type> - if undefined default to announcement - const typeParam = searchParams.get('type') ?? defaultTypeParam; - - const typeAttributes: { [key: string]: string } = { - announcement: tailwindConfig.theme.colors.green['700'], - release: tailwindConfig.theme.colors.info['600'], - vulnerability: tailwindConfig.theme.colors.warning['600'], - }; - - // use the mapped value, or if not found use announcement - const type = typeAttributes[typeParam] ?? typeAttributes[defaultTypeParam]; - - const gridBackground = `radial-gradient(circle, ${hexToRGBA(type)}, transparent)`; - - return new ImageResponse( - ( - <div tw="relative flex items-center justify-center bg-black w-[1200px] h-[600px]"> - <HexagonGrid style={{ background: gridBackground }} /> - - <div tw="absolute mx-auto flex max-w-xl flex-col text-center text-3xl font-semibold text-white"> - <JsIconWhite width={71} height={80} tw="mx-auto" /> - - <h2>{title}</h2> - </div> - </div> - ), - { width: 1200, height: 600 } - ); -}; - -// This route is fully dynamic hence there shouldn't be any static param -// available to ensure that the route is not statically generated -export const generateStaticParams = async () => [ - { locale: defaultLocale.code }, -]; - -// We want to use `edge` runtime when using Vercel -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#runtime -export const runtime = VERCEL_ENV ? 'edge' : 'nodejs'; - -// In this case we want to catch-all possible requests. This ensures that we always generate and -// serve the OpenGrapgh images independently on the locale -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = true; - -// Enforces that this route is used as static rendering -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = ENABLE_STATIC_EXPORT ? 'force-static' : 'auto'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/next-data/page-data/route.ts b/apps/site/app/[locale]/next-data/page-data/route.ts deleted file mode 100644 index b779e64569224..0000000000000 --- a/apps/site/app/[locale]/next-data/page-data/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { deflateSync } from 'node:zlib'; - -import matter from 'gray-matter'; - -import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { dynamicRouter } from '@/next.dynamic.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; -import { parseRichTextIntoPlainText } from '@/util/stringUtils'; - -// This is the Route Handler for the `GET` method which handles the request -// for a digest and metadata of all existing pages on Node.js Website -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async () => { - const allAvailbleRoutes = await dynamicRouter.getRoutesByLanguage( - defaultLocale.code - ); - - const availablePagesMetadata = allAvailbleRoutes - .filter(route => !route.startsWith('blog')) - .map(async pathname => { - const { source, filename } = await dynamicRouter.getMarkdownFile( - defaultLocale.code, - pathname - ); - - // Gets the title and the Description from the Page Metadata - const { title, description } = await dynamicRouter.getPageMetadata( - defaultLocale.code, - pathname - ); - - // Parser the Markdown source with `gray-matter` and then only - // grabs the markdown content and cleanses it by removing HTML/JSX tags - // removing empty/blank lines or lines just with spaces and trims each line - // from leading and trailing paddings/spaces - const cleanedContent = parseRichTextIntoPlainText(matter(source).content); - - // Deflates a String into a base64 string-encoded (zlib compressed) - const deflatedSource = deflateSync(cleanedContent).toString('base64'); - - // Returns metadata of each page available on the Website - return { - filename, - pathname, - title, - description, - content: deflatedSource, - }; - }); - - return Response.json(await Promise.all(availablePagesMetadata)); -}; - -// This function generates the static paths that come from the dynamic segments -// `[locale]/next-data/page-data/` and returns an array of all available static paths -// This is used for ISR static validation and generation -export const generateStaticParams = async () => [ - { locale: defaultLocale.code }, -]; - -// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = false; - -// Enforces that this route is used as static rendering -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/next-data/release-data/route.ts b/apps/site/app/[locale]/next-data/release-data/route.ts deleted file mode 100644 index a664ad9cf3ef6..0000000000000 --- a/apps/site/app/[locale]/next-data/release-data/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import provideReleaseData from '@/next-data/providers/releaseData'; -import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; -import { defaultLocale } from '@/next.locales.mjs'; - -// This is the Route Handler for the `GET` method which handles the request -// for generating static data related to the Node.js Release Data -// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers -export const GET = async () => { - const releaseData = provideReleaseData(); - - return Response.json(releaseData); -}; - -// This function generates the static paths that come from the dynamic segments -// `[locale]/next-data/release-data/` and returns an array of all available static paths -// This is used for ISR static validation and generation -export const generateStaticParams = async () => [ - { locale: defaultLocale.code }, -]; - -// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams -export const dynamicParams = false; - -// Enforces that this route is used as static rendering -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = VERCEL_REVALIDATE; diff --git a/apps/site/app/[locale]/not-found.tsx b/apps/site/app/[locale]/not-found.tsx index bb7b53628c709..4dbfb3f5202df 100644 --- a/apps/site/app/[locale]/not-found.tsx +++ b/apps/site/app/[locale]/not-found.tsx @@ -1,41 +1,32 @@ -'use client'; +import { getTranslations } from 'next-intl/server'; -import { ArrowRightIcon } from '@heroicons/react/24/solid'; -import Image from 'next/image'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; +import Button from '#site/components/Common/Button'; +import Turtle from '#site/components/Common/Turtle'; +import GlowingBackdropLayout from '#site/layouts/GlowingBackdrop'; -import Button from '@/components/Common/Button'; -import GlowingBackdrop from '@/components/Common/GlowingBackdrop'; -import CenteredLayout from '@/layouts/Centered'; +import type { FC } from 'react'; -const NotFoundPage: FC = () => { - const t = useTranslations(); +const NotFoundPage: FC = async () => { + const t = await getTranslations(); return ( - <CenteredLayout> - <GlowingBackdrop /> - - <main> - 404 - <h1 className="special -mt-4">{t('layouts.error.notFound.title')}</h1> - <div className="my-4 flex items-center justify-center"> - <Image - src="/static/images/node-mascot.svg" - alt="The Node.js mascot" - height={114.69} - width={100} - /> - </div> - <p className="-mt-4 max-w-sm text-center text-lg"> - {t('layouts.error.notFound.description')} - </p> - <Button href="/"> - {t('layouts.error.backToHome')} - <ArrowRightIcon /> - </Button> - </main> - </CenteredLayout> + <GlowingBackdropLayout kind="default"> + <span>404</span> + + <h1 className="special -mt-4 text-center"> + {t('layouts.error.notFound.title')} + </h1> + + <div className="my-4 flex h-[150px] items-center justify-center md:h-[300px]"> + <Turtle /> + </div> + + <p className="-mt-4 max-w-sm text-center text-lg"> + {t('layouts.error.notFound.description')} + </p> + + <Button href="/">{t('layouts.error.backToHome')}</Button> + </GlowingBackdropLayout> ); }; diff --git a/apps/site/app/[locale]/page.tsx b/apps/site/app/[locale]/page.tsx new file mode 100644 index 0000000000000..711e785a9a8fb --- /dev/null +++ b/apps/site/app/[locale]/page.tsx @@ -0,0 +1,86 @@ +import { defaultLocale, availableLocaleCodes } from '@node-core/website-i18n'; +import { notFound } from 'next/navigation'; + +import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; +import { ENABLE_STATIC_EXPORT_LOCALE } from '#site/next.constants.mjs'; +import * as basePage from '#site/next.dynamic.page.mjs'; + +import type { DynamicParams } from '#site/types'; +import type { FC } from 'react'; + +type PageParams = DynamicParams<{ path: Array<string> }>; + +// This is the default Viewport Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function +export const generateViewport = basePage.generateViewport; + +// This generates each page's HTML Metadata +// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata +export const generateMetadata = basePage.generateMetadata; + +/** + * Generates all possible static paths based on the locales and environment configuration + * - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) + * - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales + * - Otherwise, generates paths only for the default locale + * + * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params + */ +export const generateStaticParams = async () => { + // Return an empty array if static export is disabled + if (!ENABLE_STATIC_EXPORT) { + return []; + } + + // Determine which locales to include in the static export + const locales = ENABLE_STATIC_EXPORT_LOCALE + ? availableLocaleCodes + : [defaultLocale.code]; + + const routes = await Promise.all( + // Gets all mapped routes to the Next.js Routing Engine by Locale + locales.map(locale => ({ locale })) + ); + + return routes.flat().sort(); +}; + +// This method parses the current pathname and does any sort of modifications needed on the route +// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component +// finally it returns (if the locale and route are valid) the React Component with the relevant context +// and attached context providers for rendering the current page +const getPage: FC<PageParams> = async props => { + const { path, locale: routeLocale } = await props.params; + + // Gets the current full pathname for a given path + const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); + + // Gets the Markdown content and context + const [content, context] = await basePage.getMarkdownContext({ + locale, + pathname, + }); + + // If we have a filename and layout then we have a page + if (context.filename && context.frontmatter.layout) { + return basePage.renderPage({ + content, + layout: context.frontmatter.layout, + context, + }); + } + + return notFound(); +}; + +// Enforces that this route is used as static rendering +// Except whenever on the Development mode as we want instant-refresh when making changes +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = 300; + +export default getPage; diff --git a/apps/site/app/global-error.tsx b/apps/site/app/global-error.tsx deleted file mode 100644 index 20d1cb618a735..0000000000000 --- a/apps/site/app/global-error.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { ArrowRightIcon } from '@heroicons/react/24/solid'; -import { captureException } from '@sentry/nextjs'; -import type { FC } from 'react'; - -import Button from '@/components/Common/Button'; -import GlowingBackdrop from '@/components/Common/GlowingBackdrop'; -import BaseLayout from '@/layouts/Base'; -import CenteredLayout from '@/layouts/Centered'; - -const GlobalErrorPage: FC<{ error: Error }> = ({ error }) => { - captureException(error); - - return ( - <html> - <body> - <BaseLayout> - <CenteredLayout> - <GlowingBackdrop /> - - <main> - 500 - <h1 className="special -mt-4">Internal Server Error</h1> - <p className="-mt-4 max-w-sm text-center text-lg"> - This page has thrown a non-recoverable error. - </p> - <Button href="/"> - Back to Home - <ArrowRightIcon /> - </Button> - </main> - </CenteredLayout> - </BaseLayout> - </body> - </html> - ); -}; - -export default GlobalErrorPage; diff --git a/apps/site/app/robots.ts b/apps/site/app/robots.ts index b6c1640ada9a8..b8143d2d9bb32 100644 --- a/apps/site/app/robots.ts +++ b/apps/site/app/robots.ts @@ -1,3 +1,5 @@ +import { BASE_URL } from '#site/next.constants.mjs'; + import type { MetadataRoute } from 'next'; // This allows us to generate a `robots.txt` file dynamically based on the needs of the Node.js Website @@ -10,10 +12,16 @@ const robots = (): MetadataRoute.Robots => ({ allow: ['/dist/latest/', '/dist/latest/docs/api/', '/api/'], }, ], + sitemap: [`${BASE_URL}/sitemap.xml`, `${BASE_URL}/learn/sitemap.xml`], }); export default robots; // Enforces that this route is used as static rendering // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = false; diff --git a/apps/site/app/site.json/route.ts b/apps/site/app/site.json/route.ts new file mode 100644 index 0000000000000..075122daa9f48 --- /dev/null +++ b/apps/site/app/site.json/route.ts @@ -0,0 +1,10 @@ +import { siteConfig } from '#site/next.json.mjs'; + +export const GET = () => + Response.json(siteConfig, { + headers: { + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600', + }, + }); + +export const dynamic = 'force-static'; diff --git a/apps/site/app/sitemap.ts b/apps/site/app/sitemap.ts index 5724851ac6460..b676da5193467 100644 --- a/apps/site/app/sitemap.ts +++ b/apps/site/app/sitemap.ts @@ -1,68 +1,57 @@ -import type { MetadataRoute } from 'next'; +import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; import { BASE_PATH, BASE_URL, EXTERNAL_LINKS_SITEMAP, -} from '@/next.constants.mjs'; -import { dynamicRouter } from '@/next.dynamic.mjs'; -import { availableLocaleCodes, defaultLocale } from '@/next.locales.mjs'; +} from '#site/next.constants.mjs'; +import { BLOG_DYNAMIC_ROUTES } from '#site/next.dynamic.constants.mjs'; +import { dynamicRouter } from '#site/next.dynamic.mjs'; + +import type { MetadataRoute } from 'next'; // This is the combination of the Application Base URL and Base PATH const baseUrlAndPath = `${BASE_URL}${BASE_PATH}`; +// All available alternate locales +const nonDefaultLocales = availableLocaleCodes.filter( + l => l !== defaultLocale.code +); + +const getFullPath = (r: string, l: string) => + /^https?:\/\//.test(r) ? r : `${baseUrlAndPath}/${l}/${r}`; + +const getAlternatePaths = (r: string, locales: Array<string>) => + Object.fromEntries(locales.map(l => [l, getFullPath(r, l)])); + // This allows us to generate a `sitemap.xml` file dynamically based on the needs of the Node.js Website const sitemap = async (): Promise<MetadataRoute.Sitemap> => { - const routes = await dynamicRouter.getRoutesByLanguage(defaultLocale.code); - const paths = []; + // Gets a list of all statically available routes + const routes = await dynamicRouter.getAllRoutes(); const currentDate = new Date().toISOString(); - for (const route of routes) { - const availableLocalesForRoute = []; - - for (const locale of availableLocaleCodes.filter( - locale => locale !== defaultLocale.code - )) { - const markdownFile = await dynamicRouter.getMarkdownFile(locale, route); - const isAvailable = markdownFile.filename !== ''; - if (isAvailable) { - availableLocalesForRoute.push(locale); - } - } + const getSitemapEntry = (r: string, locales: Array<string> = []) => ({ + url: getFullPath(r, defaultLocale.code), + lastModified: currentDate, + changeFrequency: 'always' as const, + alternates: { languages: getAlternatePaths(r, locales) }, + }); - const alternatesPaths = availableLocalesForRoute.reduce( - (acc, locale) => ({ - ...acc, - [locale]: `${baseUrlAndPath}/${locale}/${route}`, - }), - {} - ); + const staticPaths = routes.map(r => getSitemapEntry(r, nonDefaultLocales)); + const blogPaths = BLOG_DYNAMIC_ROUTES.map(r => getSitemapEntry(`blog/${r}`)); + const externalPaths = EXTERNAL_LINKS_SITEMAP.map(r => getSitemapEntry(r)); - paths.push({ - url: `${baseUrlAndPath}/${defaultLocale.code}/${route}`, - lastModified: currentDate, - changeFrequency: 'always' as const, - alternates: { - languages: { - ...alternatesPaths, - }, - }, - }); - } - - return [ - ...paths, - ...EXTERNAL_LINKS_SITEMAP.map(route => ({ - url: route, - lastModified: currentDate, - changeFrequency: 'always' as const, - })), - ]; + return [...staticPaths, ...blogPaths, ...externalPaths]; }; export default sitemap; // Enforces that this route is used as static rendering // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'error'; +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = false; diff --git a/apps/site/authors.json b/apps/site/authors.json index 94713f4f4c006..20217976eec33 100644 --- a/apps/site/authors.json +++ b/apps/site/authors.json @@ -1,252 +1,320 @@ { - "Timothy J Fontaine": { - "id": "tjfontaine", - "name": "Timothy J Fontaine", - "website": "https://github.com/tjfontaine" + "Anna Henningsen": { + "id": 899444, + "name": "Anna Henningsen", + "website": "https://github.com/addaleax" }, - "Node.js Advisory Board": { - "id": "advisory-board", - "name": "Node.js Advisory Board" + "Antoine du Hamel": { + "id": 14309773, + "name": "Antoine du Hamel", + "website": "https://github.com/aduh95" }, - "Shelley Vohr": { - "id": "codebytere", - "name": "Shelley Vohr", - "website": "https://github.com/codebytere" + "AugustinMauroy": { + "id": 97875033, + "name": "Augustin Mauroy", + "website": "https://github.com/AugustinMauroy" }, - "The Node.js Project": { - "id": "nodejs", - "name": "The Node.js Project", - "website": "https://github.com/nodejs" - }, - "Tierney Cyren": { - "id": "bnb", - "name": "Tierney Cyren", - "website": "https://github.com/bnb" + "Aviv Keller": { + "id": 38299977, + "name": "Aviv Keller", + "website": "https://github.com/avivkeller" }, - "Joe Sepi": { - "id": "joesepi", - "name": "Joe Sepi", - "website": "https://github.com/joesepi" + "Ben Noordhuis": { + "id": 275871, + "name": "Ben Noordhuis", + "website": "https://github.com/bnoordhuis" }, - "Richard Lau": { - "id": "richardlau", - "name": "Richard Lau", - "website": "https://github.com/richardlau" + "Bethany Nicolle Griggs": { + "id": 8297234, + "name": "Bethany Nicolle Griggs", + "website": "https://github.com/BethGriggs" }, - "Scott Hammond": { - "id": "scott-hammond", - "name": "Scott Hammond" + "Brian Muenzenmeyer": { + "id": 298435, + "name": "Brian Muenzenmeyer", + "website": "https://github.com/bmuenzenmeyer" }, - "Mike Dolan": { - "id": "mike-dolan", - "name": "Mike Dolan" + "Bryan English": { + "id": 110455, + "name": "Bryan English", + "website": "https://github.com/bengl" }, - "Mikeal Rogers": { - "id": "mikeal", - "name": "Mikeal Rogers", - "website": "https://github.com/mikeal" + "Carl Vitullo": { + "id": 1551487, + "name": "Carl Vitullo", + "website": "https://github.com/vcarl" }, - "Rod Vagg": { - "id": "rvagg", - "name": "Rod Vagg", - "website": "https://github.com/rvagg" + "Charlie Robbins": { + "id": 4624, + "name": "Charlie Robbins", + "website": "https://github.com/indexzero" }, - "MichaÃĢl Zasso": { - "id": "targos", - "name": "MichaÃĢl Zasso", - "website": "https://github.com/targos" + "Chengzhong Wu": { + "id": 8500303, + "name": "Chengzhong Wu", + "website": "https://github.com/legendecas" }, - "Tracy Hinds": { - "id": "tracy-hinds", - "name": "Tracy Hinds" + "Claudio Wunder": { + "id": 12037269, + "name": "Claudio Wunder", + "website": "https://github.com/ovflowd" }, - "Michael Dawson": { - "id": "mhdawson", - "name": "Michael Dawson", - "website": "https://github.com/mhdawson" + "Colin Ihrig": { + "id": 2512748, + "name": "Colin Ihrig", + "website": "https://github.com/cjihrig" }, - "Myles Borins": { - "id": "mylesborins", - "name": "Myles Borins", - "website": "https://github.com/mylesborins" + "Danielle Adams": { + "id": 6271256, + "name": "Danielle Adams", + "website": "https://github.com/danielleadams" }, - "Isaac Schlueter": { - "id": "isaacs", - "name": "Isaac Schlueter", - "website": "https://github.com/isaacs" + "Daniel Bevenius": { + "id": 432351, + "name": "Daniel Bevenius", + "website": "https://github.com/danbev" }, "Dave Pacheco": { - "id": "dave-pacheco", + "id": 70340013, "name": "Dave Pacheco" }, "Domenic Denicola": { - "id": "domenic", + "id": 617481, "name": "Domenic Denicola", "website": "https://github.com/domenic" }, - "Charlie Robbins": { - "id": "indexzero", - "name": "Charlie Robbins", - "website": "https://github.com/indexzero" + "Emelia Smith": { + "id": 30827, + "name": "Emelia Smith", + "website": "https://github.com/thisismissem" }, - "Ryan Dahl": { - "id": "ry", - "name": "Ryan Dahl", - "website": "https://github.com/ry" + "Emily Rose": { + "id": 32688683, + "name": "Emily Rose", + "website": "https://github.com/emilyrose" }, - "ryandahl": { - "id": "ry", - "name": "Ryan Dahl", - "website": "https://github.com/ry" + "Evan Lucas": { + "id": 677994, + "name": "Evan Lucas", + "website": "https://github.com/evanlucas" }, - "Ben Noordhuis": { - "id": "bennoordhuis", - "name": "Ben Noordhuis", - "website": "https://github.com/bnoordhuis" + "flakey5": { + "id": 73616808, + "name": "flakey5", + "website": "https://github.com/flakey5" }, - "Bert Belder": { - "id": "piscisaureus", - "name": "Bert Belder", - "website": "https://github.com/piscisaureus" + "Gibson Fahnestock": { + "id": 15943089, + "name": "Gibson Fahnestock", + "website": "https://github.com/gibfahn" }, - "Jeremiah Senkpiel": { - "id": "Fishrock123", - "name": "Jeremiah Senkpiel", - "website": "https://github.com/Fishrock123" + "Giovanny Gongora": { + "id": 1834718, + "name": "Giovanny Gongora", + "website": "https://github.com/Gioyik" }, - "Colin Ihrig": { - "id": "cjihrig", - "name": "Colin Ihrig", - "website": "https://github.com/cjihrig" + "Guilherme AraÃējo": { + "id": 68445491, + "name": "Guilherme AraÃējo", + "website": "https://github.com/araujogui" }, - "Evan Lucas": { - "id": "evanlucas", - "name": "Evan Lucas", - "website": "https://github.com/evanlucas" + "Isaac Schlueter": { + "id": 9287, + "name": "Isaac Schlueter", + "website": "https://github.com/isaacs" + }, + "Italo Casas": { + "id": 6354455, + "name": "Italo Casas", + "website": "https://github.com/italoacasas" }, "James M Snell": { - "id": "jasnell", + "id": 439929, "name": "James M Snell", "website": "https://github.com/jasnell" }, - "Italo Casas": { - "id": "italoacasas", - "name": "Italo Casas", - "website": "https://github.com/italoacasas" + "Jeremiah Senkpiel": { + "id": 1093990, + "name": "Jeremiah Senkpiel", + "website": "https://github.com/Fishrock123" }, - "Anna Henningsen": { - "id": "addaleax", - "name": "Anna Henningsen", - "website": "https://github.com/addaleax" + "Joe Sepi": { + "id": 143668, + "name": "Joe Sepi", + "website": "https://github.com/joesepi" }, - "Gibson Fahnestock": { - "id": "gibfahn", - "name": "Gibson Fahnestock", - "website": "https://github.com/gibfahn" + "Joyee Cheung": { + "id": 4299420, + "name": "Joyee Cheung", + "website": "https://github.com/joyeecheung" }, - "Ruben Bridgewater": { - "id": "BridgeAR", - "name": "Ruben Bridgewater", - "website": "https://github.com/BridgeAR" + "Juan JosÊ": { + "id": 17013303, + "name": "Juan JosÊ Arboleda", + "website": "https://github.com/juanarbol" }, - "Bethany Nicolle Griggs": { - "id": "BethGriggs", - "name": "Bethany Nicolle Griggs", - "website": "https://github.com/BethGriggs" + "Juan JosÊ Arboleda": { + "id": 17013303, + "name": "Juan JosÊ Arboleda", + "website": "https://github.com/juanarbol" }, - "Ruy Adorno": { - "id": "ruyadorno", - "name": "Ruy Adorno", - "website": "https://github.com/ruyadorno" + "JuliÃĄn Duque": { + "id": 733877, + "name": "JuliÃĄn Duque", + "website": "https://github.com/julianduque" }, - "Danielle Adams": { - "id": "danielleadams", - "name": "Danielle Adams", - "website": "https://github.com/danielleadams" + "Node.js Releasers": { + "id": 9950313, + "name": "Node.js Release Working Group", + "website": "https://github.com/nodejs/release" }, - "Bryan English": { - "id": "bengl", - "name": "Bryan English", - "website": "https://github.com/bengl" + "Node.js Technical Steering Committee": { + "id": 9950313, + "name": "Node.js Technical Steering Committee", + "website": "https://github.com/nodejs/tsc" }, - "Stewart X Addison": { - "id": "sxa", - "name": "Stewart X Addison", - "website": "https://github.com/sxa" + "Matteo Collina": { + "id": 52195, + "name": "Matteo Collina", + "website": "https://github.com/mcollina" }, - "Emily Rose": { - "id": "emilyrose", - "name": "Emily Rose", - "website": "https://github.com/emilyrose" + "Marco Ippolito": { + "id": 36735501, + "name": "Marco Ippolito", + "website": "https://github.com/marco-ippolito" }, - "JuliÃĄn Duque": { - "id": "julianduque", - "name": "JuliÃĄn Duque", - "website": "https://github.com/julianduque" + "Matt Cowley": { + "id": 12371363, + "name": "Matt Cowley", + "website": "https://github.com/MattIPv4" }, - "Giovanny Gongora": { - "id": "Gioyik", - "name": "Giovanny Gongora", - "website": "https://github.com/Gioyik" + "Michael Dawson": { + "id": 9373002, + "name": "Michael Dawson", + "website": "https://github.com/mhdawson" }, - "Yosuke Furukawa": { - "id": "yosuke-furukawa", - "name": "Yosuke Furukawa", - "website": "https://github.com/yosuke-furukawa" + "MichaÃĢl Zasso": { + "id": 2352663, + "name": "MichaÃĢl Zasso", + "website": "https://github.com/targos" }, - "Steven Sinatra": { - "id": "diagramatics", - "name": "Steven Sinatra", - "website": "https://github.com/diagramatics" + "Mike Dolan": { + "id": 7398917, + "name": "Mike Dolan", + "website": "https://github.com/mkdolan" + }, + "Mikeal Rogers": { + "id": 579, + "name": "Mikeal Rogers", + "website": "https://github.com/mikeal" }, "Minwoo Jung": { - "id": "minwoo-jung", + "id": 4557258, "name": "Minwoo Jung", "website": "https://github.com/minwoo-jung" }, + "Myles Borins": { + "id": 498775, + "name": "Myles Borins", + "website": "https://github.com/mylesborins" + }, + "piscisaureus": { + "id": 218257, + "name": "Bert Belder", + "website": "https://github.com/piscisaureus" + }, "Rafael Gonzaga": { - "id": "RafaelGSS", + "id": 26234614, "name": "Rafael Gonzaga", "website": "https://github.com/RafaelGSS" }, - "Joyee Cheung": { - "id": "joyeecheung", - "name": "Joyee Cheung", - "website": "https://github.com/joyeecheung" + "Richard Lau": { + "id": 5445507, + "name": "Richard Lau", + "website": "https://github.com/richardlau" }, - "Juan JosÊ Arboleda": { - "id": "juanarbol", - "name": "Juan JosÊ Arboleda", - "website": "https://github.com/juanarbol" + "Robin Bender Ginn": { + "id": 4296937, + "name": "Robin Bender Ginn", + "website": "https://github.com/rginn" }, - "Juan JosÊ": { - "id": "juanarbol", - "name": "Juan JosÊ Arboleda", - "website": "https://github.com/juanarbol" + "Rod Vagg": { + "id": 495647, + "name": "Rod Vagg", + "website": "https://github.com/rvagg" }, - "Brian Muenzenmeyer": { - "id": "bmuenzenmeyer", - "name": "Brian Muenzenmeyer", - "website": "https://github.com/bmuenzenmeyer" + "Ruben Bridgewater": { + "id": 8822573, + "name": "Ruben Bridgewater", + "website": "https://github.com/BridgeAR" }, - "Marco Ippolito": { - "id": "marco-ippolito", - "name": "Marco Ippolito", - "website": "https://github.com/marco-ippolito" + "Ruy Adorno": { + "id": 220900, + "name": "Ruy Adorno", + "website": "https://github.com/ruyadorno" + }, + "Ryan Dahl": { + "id": 80, + "name": "Ryan Dahl", + "website": "https://github.com/ry" + }, + "Sam Roberts": { + "id": 17607, + "name": "Sam Roberts", + "website": "https://github.com/sam-github" + }, + "Scott Hammond": { + "id": 95775645, + "name": "Scott Hammond" + }, + "Shelley Vohr": { + "id": 2036040, + "name": "Shelley Vohr", + "website": "https://github.com/codebytere" + }, + "Stewart X Addison": { + "id": 6487691, + "name": "Stewart X Addison", + "website": "https://github.com/sxa" + }, + "Steven Sinatra": { + "id": 5153378, + "name": "Steven Sinatra", + "website": "https://github.com/diagramatics" + }, + "The Node.js Project": { + "id": 9950313, + "name": "The Node.js Project", + "website": "https://github.com/nodejs" + }, + "Tierney Cyren": { + "id": 502396, + "name": "Tierney Cyren", + "website": "https://github.com/bnb" + }, + "Timothy J Fontaine": { + "id": 146447, + "name": "Timothy J Fontaine", + "website": "https://github.com/tjfontaine" + }, + "Tracy Hinds": { + "id": 1981088, + "name": "Tracy Hinds", + "website": "https://github.com/hackygolucky" }, "Ulises GascÃŗn": { - "id": "UlisesGascon", + "id": 5110813, "name": "Ulises GascÃŗn", "website": "https://github.com/UlisesGascon" }, - "Claudio Wunder": { - "id": "ovflowd", - "name": "Claudio Wunder", - "website": "https://github.com/ovflowd" + "Vladimir de Turckheim": { + "id": 7135896, + "name": "Vladimir de Turckheim", + "website": "https://github.com/vdeturckheim" }, - "Matt Cowley": { - "id": "MattIPv4", - "name": "Matt Cowley", - "website": "https://github.com/MattIPv4" + "Yosuke Furukawa": { + "id": 555645, + "name": "Yosuke Furukawa", + "website": "https://github.com/yosuke-furukawa" } } diff --git a/apps/site/client-context.ts b/apps/site/client-context.ts index 0ee4c5e38d04f..28f437c014782 100644 --- a/apps/site/client-context.ts +++ b/apps/site/client-context.ts @@ -1,7 +1,8 @@ import { cache } from 'react'; -import type { ClientSharedServerContext } from '@/types'; -import { assignClientContext } from '@/util/assignClientContext'; +import { assignClientContext } from '#site/util/context'; + +import type { ClientSharedServerContext } from '#site/types'; // This allows us to have Server-Side Context's of the shared "contextual" data // which includes the frontmatter, the current pathname from the dynamic segments diff --git a/apps/site/components/Blog/BlogHeader/__tests__/index.test.jsx b/apps/site/components/Blog/BlogHeader/__tests__/index.test.jsx new file mode 100644 index 0000000000000..83ef08a47e5e6 --- /dev/null +++ b/apps/site/components/Blog/BlogHeader/__tests__/index.test.jsx @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { render, screen } from '@testing-library/react'; + +import BlogHeader from '#site/components/Blog/BlogHeader'; + +describe('BlogHeader', () => { + it('should have correct href when category is all', () => { + render(<BlogHeader category="all" />); + const link = screen.getByRole('link'); + assert.equal(link.getAttribute('href'), '/feed/blog.xml'); + }); + + it('should have correct href when category is release', () => { + render(<BlogHeader category="release" />); + const link = screen.getByRole('link'); + assert.equal(link.getAttribute('href'), '/feed/releases.xml'); + }); + + it('should have correct href when category is vulnerability', () => { + render(<BlogHeader category="vulnerability" />); + const link = screen.getByRole('link'); + assert.equal(link.getAttribute('href'), '/feed/vulnerability.xml'); + }); + + it('should have correct href when category is random', () => { + render(<BlogHeader category="random" />); + const link = screen.getByRole('link'); + assert.equal(link.getAttribute('href'), '/feed/blog.xml'); + }); +}); diff --git a/apps/site/components/Blog/BlogHeader/__tests__/index.test.mjs b/apps/site/components/Blog/BlogHeader/__tests__/index.test.mjs deleted file mode 100644 index ec47f6c1f043b..0000000000000 --- a/apps/site/components/Blog/BlogHeader/__tests__/index.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import BlogHeader from '@/components/Blog/BlogHeader'; - -describe('BlogHeader', () => { - it('should have correct href when category is all', () => { - render(<BlogHeader category="all" />); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/feed/blog.xml'); - }); - - it('should have correct href when category is release', () => { - render(<BlogHeader category="release" />); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/feed/releases.xml'); - }); - - it('should have correct href when category is vulnerability', () => { - render(<BlogHeader category="vulnerability" />); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/feed/vulnerability.xml'); - }); - - it('should have correct href when category is random', () => { - render(<BlogHeader category="random" />); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/feed/blog.xml'); - }); -}); diff --git a/apps/site/components/Blog/BlogHeader/index.module.css b/apps/site/components/Blog/BlogHeader/index.module.css index b7a0764768f5f..c34522e25d42a 100644 --- a/apps/site/components/Blog/BlogHeader/index.module.css +++ b/apps/site/components/Blog/BlogHeader/index.module.css @@ -1,3 +1,5 @@ +@reference "../../../styles/index.css"; + .blogHeader { h1 { @apply inline-flex diff --git a/apps/site/components/Blog/BlogHeader/index.stories.tsx b/apps/site/components/Blog/BlogHeader/index.stories.tsx deleted file mode 100644 index 0e29b475119bc..0000000000000 --- a/apps/site/components/Blog/BlogHeader/index.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import BlogHeader from '@/components/Blog/BlogHeader'; - -type Story = StoryObj<typeof BlogHeader>; -type Meta = MetaObj<typeof BlogHeader>; - -export const Default: Story = { - args: { - // See `@/site.json` for the `rssFeeds` object - category: 'all', - }, - decorators: [ - // We need to wrap to allow global styles to be applied (markdown styles) - Story => ( - <main> - <Story /> - </main> - ), - ], -}; - -export default { component: BlogHeader } as Meta; diff --git a/apps/site/components/Blog/BlogHeader/index.tsx b/apps/site/components/Blog/BlogHeader/index.tsx index 11c3bc7ec9376..9b209ebb595fd 100644 --- a/apps/site/components/Blog/BlogHeader/index.tsx +++ b/apps/site/components/Blog/BlogHeader/index.tsx @@ -1,23 +1,20 @@ -'use client'; - import { RssIcon } from '@heroicons/react/24/solid'; import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; -import Link from '@/components/Link'; -import { siteConfig } from '@/next.json.mjs'; +import Link from '#site/components/Link'; +import { siteConfig } from '#site/next.json.mjs'; + +import type { FC } from 'react'; import styles from './index.module.css'; -type BlogHeaderProps = { - category: string; -}; +type BlogHeaderProps = { category: string }; const BlogHeader: FC<BlogHeaderProps> = ({ category }) => { const t = useTranslations(); - const currentFile = - siteConfig.rssFeeds.find(item => item.category === category)?.file ?? - 'blog.xml'; + + const feed = siteConfig.rssFeeds.find(item => item.category === category); + const currentFile = feed ? feed.file : 'blog.xml'; return ( <header className={styles.blogHeader}> @@ -25,6 +22,7 @@ const BlogHeader: FC<BlogHeaderProps> = ({ category }) => { {t('layouts.blog.title')} <Link href={`/feed/${currentFile}`} + locale="en" aria-label={t('components.blog.blogHeader.rssLink')} > <RssIcon /> diff --git a/apps/site/components/Blog/BlogPostCard/__tests__/index.test.jsx b/apps/site/components/Blog/BlogPostCard/__tests__/index.test.jsx new file mode 100644 index 0000000000000..a0a09b8187055 --- /dev/null +++ b/apps/site/components/Blog/BlogPostCard/__tests__/index.test.jsx @@ -0,0 +1,117 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { render, screen } from '@testing-library/react'; + +import { isVisible } from '../../../../../../tests/utilities.mjs'; + +import BlogPostCard from '#site/components/Blog/BlogPostCard'; + +function renderBlogPostCard({ + title = 'Blog post title', + type = 'vulnerability', + description = 'Blog post description', + authors = [], + date = new Date(), + slug = '', +}) { + render( + <BlogPostCard + title={title} + category={type} + description={description} + authors={authors} + date={date} + slug={slug} + /> + ); + + return { title, type, description, authors, date }; +} + +describe('BlogPostCard', () => { + describe('Rendering', () => { + it('Wraps the entire card within an article', () => { + renderBlogPostCard({}); + + assert.ok(isVisible(screen.getByRole('article'))); + }); + + it('Renders the title prop correctly', () => { + const { title } = renderBlogPostCard({}); + + assert.equal(screen.getAllByText(title).length, 2); + + // title from preview should be ignored as the one from Links + // and blog card/post are what matter + assert.equal( + screen.getAllByText(title)[0].getAttribute('aria-hidden'), + 'true' + ); + }); + + it('Renders the description prop correctly', () => { + const { description } = renderBlogPostCard({}); + + assert.ok(isVisible(screen.getByText(description))); + }); + + [ + { label: 'layouts.blog.categories.vulnerability', type: 'vulnerability' }, + { label: 'layouts.blog.categories.announcements', type: 'announcements' }, + { label: 'layouts.blog.categories.release', type: 'release' }, + ].forEach(({ label, type }) => { + it(`Renders "${label} text when passing it the type "${type}`, () => { + renderBlogPostCard({ type }); + + assert.ok(isVisible(screen.getByText(label))); + }); + }); + + it('Renders all passed authors fullName(s), comma-separated', () => { + const authors = ['Jane Doe', 'John Doe']; + + renderBlogPostCard({ authors }); + + const fullNames = authors.reduce((prev, curr, index) => { + if (index === 0) { + return curr; + } + + return `${prev}, ${curr}`; + }, ''); + + assert.ok(isVisible(screen.getByText(fullNames))); + }); + + it('Renders all passed authors fullName(s), comma-separated', () => { + const authors = ['Jane Doe', 'John Doe']; + + renderBlogPostCard({ authors }); + + const fullNames = authors.reduce((prev, curr, index) => { + if (index === 0) { + return curr; + } + + return `${prev}, ${curr}`; + }, ''); + + assert.ok(isVisible(screen.getByText(fullNames))); + }); + + it('Renders date prop in short format', () => { + const date = new Date(); + + renderBlogPostCard({ date }); + + const dateTimeFormat = new Intl.DateTimeFormat('en-US', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + + assert.ok(isVisible(screen.getByText(dateTimeFormat.format(date)))); + }); + }); +}); diff --git a/apps/site/components/Blog/BlogPostCard/index.module.css b/apps/site/components/Blog/BlogPostCard/index.module.css new file mode 100644 index 0000000000000..e9e8f6a26c377 --- /dev/null +++ b/apps/site/components/Blog/BlogPostCard/index.module.css @@ -0,0 +1,57 @@ +@reference "../../../styles/index.css"; + +.container { + @apply max-w-full + flex-1; +} + +.subtitle { + @apply text-brand-600 + dark:text-brand-400 + mt-6 + mb-2 + inline-block + text-xs + font-semibold; +} + +.title { + @apply mb-2 + block + text-xl + font-semibold + text-neutral-900 + dark:text-white; +} + +.description { + @apply mb-6 + line-clamp-3 + text-sm + text-neutral-800 + dark:text-neutral-200; +} + +.footer { + @apply flex + gap-x-3; + + div:first-child { + @apply overflow-visible; + } +} + +.author { + p { + @apply text-sm + font-semibold + text-neutral-900 + dark:text-white; + } + + time { + @apply text-sm + text-neutral-800 + dark:text-neutral-200; + } +} diff --git a/apps/site/components/Blog/BlogPostCard/index.tsx b/apps/site/components/Blog/BlogPostCard/index.tsx new file mode 100644 index 0000000000000..0951d52022a11 --- /dev/null +++ b/apps/site/components/Blog/BlogPostCard/index.tsx @@ -0,0 +1,64 @@ +import Preview from '@node-core/ui-components/Common/Preview'; +import { useTranslations } from 'next-intl'; + +import FormattedTime from '#site/components/Common/FormattedTime'; +import Link from '#site/components/Link'; +import WithAvatarGroup from '#site/components/withAvatarGroup'; +import { mapBlogCategoryToPreviewType } from '#site/util/blog'; + +import type { BlogCategory } from '#site/types'; +import type { FC } from 'react'; + +import styles from './index.module.css'; + +type BlogPostCardProps = { + title: string; + category: BlogCategory; + description?: string; + authors?: Array<string>; + date?: string | Date; + slug?: string; +}; + +const BlogPostCard: FC<BlogPostCardProps> = ({ + title, + slug, + category, + description, + authors = [], + date, +}) => { + const t = useTranslations(); + + const type = mapBlogCategoryToPreviewType(category); + + return ( + <article className={styles.container}> + <Link href={slug} aria-label={title}> + <Preview title={title} type={type} /> + </Link> + + <Link href={`/blog/${category}`} className={styles.subtitle}> + {t(`layouts.blog.categories.${category}`)} + </Link> + + <Link href={slug} className={styles.title}> + {title} + </Link> + + {description && <p className={styles.description}>{description}</p>} + + <footer className={styles.footer}> + <WithAvatarGroup names={authors} size="medium" clickable={false} /> + + <div className={styles.author}> + {authors && <p>{authors.join(', ')}</p>} + + {date && <FormattedTime date={date} />} + </div> + </footer> + </article> + ); +}; + +export default BlogPostCard; diff --git a/apps/site/components/Common/ActiveLink/__tests__/index.test.mjs b/apps/site/components/Common/ActiveLink/__tests__/index.test.mjs deleted file mode 100644 index 2ce5778f60b63..0000000000000 --- a/apps/site/components/Common/ActiveLink/__tests__/index.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import ActiveLink from '..'; - -describe('ActiveLink', () => { - it('renders as localized link', () => { - render( - <ActiveLink className="link" activeClassName="active" href="/link"> - Link - </ActiveLink> - ); - - expect(screen.findByText('Link')).resolves.toHaveAttribute( - 'href', - '/en/link' - ); - }); - - it('ignores active class when href not matches location.href', () => { - render( - <ActiveLink className="link" activeClassName="active" href="/not-link"> - Link - </ActiveLink> - ); - - expect(screen.findByText('Link')).resolves.toHaveAttribute('class', 'link'); - }); - - it('sets active class when href matches location.href', () => { - render( - <ActiveLink className="link" activeClassName="active" href="/link"> - Link - </ActiveLink> - ); - - expect(screen.findByText('Link')).resolves.toHaveAttribute( - 'class', - 'link active' - ); - }); -}); diff --git a/apps/site/components/Common/ActiveLink/index.tsx b/apps/site/components/Common/ActiveLink/index.tsx deleted file mode 100644 index bdf566d87dc71..0000000000000 --- a/apps/site/components/Common/ActiveLink/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import classNames from 'classnames'; -import type { ComponentProps, FC } from 'react'; - -import Link from '@/components/Link'; -import { usePathname } from '@/navigation.mjs'; - -type ActiveLocalizedLinkProps = ComponentProps<typeof Link> & { - activeClassName?: string; - allowSubPath?: boolean; -}; - -const ActiveLink: FC<ActiveLocalizedLinkProps> = ({ - children, - activeClassName = 'active', - allowSubPath = false, - className, - href = '', - ...props -}) => { - const pathname = usePathname(); - - const finalClassName = classNames(className, { - [activeClassName]: allowSubPath - ? // When using allowSubPath we want only to check if - // the current pathname starts with the utmost upper level - // of an href (e.g. /docs/...) - pathname.startsWith(`/${href.toString().split('/')[1]}`) - : href.toString() === pathname, - }); - - return ( - <Link className={finalClassName} href={href} {...props}> - {children} - </Link> - ); -}; - -export default ActiveLink; diff --git a/apps/site/components/Common/AvatarGroup/Avatar/index.module.css b/apps/site/components/Common/AvatarGroup/Avatar/index.module.css deleted file mode 100644 index 616144b872ba2..0000000000000 --- a/apps/site/components/Common/AvatarGroup/Avatar/index.module.css +++ /dev/null @@ -1,23 +0,0 @@ -.avatar { - @apply flex - size-8 - items-center - justify-center - rounded-full - border-2 - border-white - bg-neutral-100 - object-cover - text-xs - text-neutral-800 - dark:border-neutral-950 - dark:bg-neutral-900 - dark:text-neutral-300; -} - -.avatarRoot { - @apply -ml-2 - size-8 - flex-shrink-0 - first:ml-0; -} diff --git a/apps/site/components/Common/AvatarGroup/Avatar/index.stories.tsx b/apps/site/components/Common/AvatarGroup/Avatar/index.stories.tsx deleted file mode 100644 index 7f3a6430d87b8..0000000000000 --- a/apps/site/components/Common/AvatarGroup/Avatar/index.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Avatar from '@/components/Common/AvatarGroup/Avatar'; -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -type Story = StoryObj<typeof Avatar>; -type Meta = MetaObj<typeof Avatar>; - -export const Default: Story = { - args: { - src: getGitHubAvatarUrl('ovflowd'), - alt: 'ovflowd', - }, -}; - -export const NoSquare: Story = { - args: { - src: '/static/images/logos/nodejs.png', - alt: 'SD', - }, -}; - -export const FallBack: Story = { - args: { - src: 'https://avatars.githubusercontent.com/u/', - alt: 'UA', - }, -}; - -export default { component: Avatar } as Meta; diff --git a/apps/site/components/Common/AvatarGroup/Avatar/index.tsx b/apps/site/components/Common/AvatarGroup/Avatar/index.tsx deleted file mode 100644 index 36122f839126f..0000000000000 --- a/apps/site/components/Common/AvatarGroup/Avatar/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as RadixAvatar from '@radix-ui/react-avatar'; -import type { FC } from 'react'; - -import styles from './index.module.css'; - -export type AvatarProps = { - src: string; - alt: string; -}; - -const Avatar: FC<AvatarProps> = ({ src, alt }) => ( - <RadixAvatar.Root className={styles.avatarRoot}> - <RadixAvatar.Image - loading="lazy" - src={src} - alt={alt} - className={styles.avatar} - /> - <RadixAvatar.Fallback delayMs={500} className={styles.avatar}> - {alt} - </RadixAvatar.Fallback> - </RadixAvatar.Root> -); - -export default Avatar; diff --git a/apps/site/components/Common/AvatarGroup/__tests__/index.test.mjs b/apps/site/components/Common/AvatarGroup/__tests__/index.test.mjs deleted file mode 100644 index f04c7c1dbeb26..0000000000000 --- a/apps/site/components/Common/AvatarGroup/__tests__/index.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { render, fireEvent } from '@testing-library/react'; - -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -import AvatarGroup from '../index'; - -const names = [ - 'ovflowd', - 'bmuenzenmeyer', - 'AugustinMauroy', - 'HinataKah0', - 'Harkunwar', - 'rodion-arr', - 'mikeesto', - 'bnb', - 'benhalverson', - 'aymen94', - 'shanpriyan', - 'Wai-Dung', - 'manishprivet', - 'araujogui', -]; - -const avatars = names.map(name => ({ - src: getGitHubAvatarUrl(name), - alt: name, -})); - -describe('AvatarGroup', () => { - it('renders the AvatarGroup component properly', () => { - const { getByText } = render(<AvatarGroup avatars={avatars} limit={2} />); - - const showMoreButton = getByText('+12'); - expect(showMoreButton).toBeInTheDocument(); - }); - - it('displays the rest of the avatars when "show more" button is clicked', () => { - const { getByText } = render(<AvatarGroup avatars={avatars} limit={2} />); - - const showMoreButton = getByText('+12'); - fireEvent.click(showMoreButton); - - const hideMoreButton = getByText('-12'); - expect(hideMoreButton).toBeInTheDocument(); - }); -}); diff --git a/apps/site/components/Common/AvatarGroup/index.module.css b/apps/site/components/Common/AvatarGroup/index.module.css deleted file mode 100644 index 3e01660bddc07..0000000000000 --- a/apps/site/components/Common/AvatarGroup/index.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.avatarGroup { - @apply -mb-4 - flex - items-center - overflow-x-auto - pb-4; -} diff --git a/apps/site/components/Common/AvatarGroup/index.stories.tsx b/apps/site/components/Common/AvatarGroup/index.stories.tsx deleted file mode 100644 index 6aef57bb904ed..0000000000000 --- a/apps/site/components/Common/AvatarGroup/index.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import AvatarGroup from '@/components/Common/AvatarGroup'; -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -type Story = StoryObj<typeof AvatarGroup>; -type Meta = MetaObj<typeof AvatarGroup>; - -const names = [ - 'ovflowd', - 'bmuenzenmeyer', - 'AugustinMauroy', - 'HinataKah0', - 'Harkunwar', - 'rodion-arr', - 'mikeesto', - 'bnb', - 'benhalverson', - 'aymen94', - 'shanpriyan', - 'Wai-Dung', - 'manishprivet', - 'araujogui', -]; - -const unknownAvatar = { - src: 'https://avatars.githubusercontent.com/u/', - alt: 'unknown-avatar', -}; - -const defaultProps = { - avatars: [ - unknownAvatar, - ...names.map(name => ({ src: getGitHubAvatarUrl(name), alt: name })), - ], -}; - -export const Default: Story = { - args: { ...defaultProps }, -}; - -export const WithCustomLimit: Story = { - args: { - ...defaultProps, - limit: 5, - }, -}; - -export const InSmallContainer: Story = { - decorators: [ - Story => ( - <div className="w-[150px]"> - <Story /> - </div> - ), - ], - args: { ...defaultProps, limit: 5 }, -}; - -export default { component: AvatarGroup } as Meta; diff --git a/apps/site/components/Common/AvatarGroup/index.tsx b/apps/site/components/Common/AvatarGroup/index.tsx deleted file mode 100644 index b7172ec8dfc6b..0000000000000 --- a/apps/site/components/Common/AvatarGroup/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import classNames from 'classnames'; -import type { ComponentProps, FC } from 'react'; -import { useState, useMemo } from 'react'; - -import Avatar from '@/components/Common/AvatarGroup/Avatar'; -import avatarstyles from '@/components/Common/AvatarGroup/Avatar/index.module.css'; -import { getAcronymFromString } from '@/util/stringUtils'; - -import styles from './index.module.css'; - -type AvatarGroupProps = { - avatars: Array<ComponentProps<typeof Avatar>>; - limit?: number; - isExpandable?: boolean; -}; - -const AvatarGroup: FC<AvatarGroupProps> = ({ - avatars, - limit = 10, - isExpandable = true, -}) => { - const [showMore, setShowMore] = useState(false); - - const renderAvatars = useMemo( - () => avatars.slice(0, showMore ? avatars.length : limit), - [showMore, avatars, limit] - ); - - return ( - <div className={styles.avatarGroup}> - {renderAvatars.map((avatar, index) => ( - <Avatar - src={avatar.src} - alt={getAcronymFromString(avatar.alt)} - key={index} - /> - ))} - - {avatars.length > limit && ( - <span - onClick={isExpandable ? () => setShowMore(prev => !prev) : undefined} - className={classNames(avatarstyles.avatarRoot, 'cursor-pointer')} - > - <span className={avatarstyles.avatar}> - {`${showMore ? '-' : '+'}${avatars.length - limit}`} - </span> - </span> - )} - </div> - ); -}; - -export default AvatarGroup; diff --git a/apps/site/components/Common/Badge/index.module.css b/apps/site/components/Common/Badge/index.module.css deleted file mode 100644 index 40c28b3285188..0000000000000 --- a/apps/site/components/Common/Badge/index.module.css +++ /dev/null @@ -1,99 +0,0 @@ -.wrapper { - @apply flex - w-fit - items-center - rounded-full - border - py-1 - pl-1 - pr-2.5 - text-sm - font-medium; - - .icon { - @apply size-4; - } - - .badge { - @apply mr-2 - rounded-full - border - px-2.5 - py-0.5 - text-center - text-white; - } - - .message { - @apply mr-1; - } - - &.default { - @apply border-green-200 - bg-green-100 - dark:border-green-700 - dark:bg-neutral-900; - - .icon { - @apply text-green-500 - dark:text-green-300; - } - - .badge { - @apply border-green-200 - bg-green-600 - dark:border-green-600; - } - - .message { - @apply text-green-700 - dark:text-green-300; - } - } - - &.error { - @apply border-danger-200 - bg-danger-100 - dark:border-danger-700 - dark:bg-neutral-900; - - .icon { - @apply text-danger-500 - dark:text-danger-300; - } - - .badge { - @apply border-danger-200 - bg-danger-600 - dark:border-danger-600; - } - - .message { - @apply text-danger-700 - dark:text-danger-300; - } - } - - &.warning { - @apply border-warning-200 - bg-warning-100 - dark:border-warning-700 - dark:bg-neutral-900; - - .icon { - @apply text-warning-500 - dark:text-warning-300; - } - - .badge { - @apply border-warning-200 - bg-warning-600 - dark:border-warning-600; - } - - .message { - @apply text-warning-700 - dark:text-warning-300; - } - } -} diff --git a/apps/site/components/Common/Badge/index.stories.tsx b/apps/site/components/Common/Badge/index.stories.tsx deleted file mode 100644 index 0b200b997c009..0000000000000 --- a/apps/site/components/Common/Badge/index.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Badge from '@/components/Common/Badge'; - -type Story = StoryObj<typeof Badge>; -type Meta = MetaObj<typeof Badge>; - -export const Default: Story = { - args: { - href: '/', - children: 'OpenJS Foundation Certification 2023', - kind: 'default', - badgeText: 'New', - }, -}; - -export const Error: Story = { - args: { - href: '/', - children: 'OpenJS Foundation Certification 2023', - kind: 'error', - badgeText: 'New', - }, -}; - -export const Warning: Story = { - args: { - href: '/', - children: 'OpenJS Foundation Certification 2023', - kind: 'warning', - badgeText: 'New', - }, -}; - -export default { component: Badge } as Meta; diff --git a/apps/site/components/Common/Badge/index.tsx b/apps/site/components/Common/Badge/index.tsx deleted file mode 100644 index 8463fccf52e62..0000000000000 --- a/apps/site/components/Common/Badge/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import ArrowRightIcon from '@heroicons/react/24/solid/ArrowRightIcon'; -import type { ComponentProps, FC, PropsWithChildren } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type BadgeProps = { - kind?: 'default' | 'warning' | 'error'; - badgeText?: string; -} & ComponentProps<typeof Link>; - -const Badge: FC<PropsWithChildren<BadgeProps>> = ({ - kind = 'default', - badgeText, - children, - ...args -}) => ( - <Link className={`${styles.wrapper} ${styles[kind]}`} {...args}> - {badgeText && <span className={styles.badge}>{badgeText}</span>} - <span className={styles.message}>{children}</span> - {args.href && <ArrowRightIcon className={styles.icon} />} - </Link> -); - -export default Badge; diff --git a/apps/site/components/Common/Banner/index.module.css b/apps/site/components/Common/Banner/index.module.css deleted file mode 100644 index f3c2b9c1ec77c..0000000000000 --- a/apps/site/components/Common/Banner/index.module.css +++ /dev/null @@ -1,40 +0,0 @@ -.banner { - @apply flex - w-full - flex-row - items-center - justify-center - gap-2 - px-8 - py-3 - text-sm; - - &, - a { - @apply text-white - dark:text-white; - } - - a { - @apply w-fit - underline - decoration-white/50; - } - - svg { - @apply size-4 - text-white/50; - } -} - -.default { - @apply bg-green-600; -} - -.error { - @apply bg-danger-600; -} - -.warning { - @apply bg-warning-600; -} diff --git a/apps/site/components/Common/Banner/index.stories.tsx b/apps/site/components/Common/Banner/index.stories.tsx deleted file mode 100644 index cf489c8d983a6..0000000000000 --- a/apps/site/components/Common/Banner/index.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Banner from '@/components/Common/Banner'; - -type Story = StoryObj<typeof Banner>; -type Meta = MetaObj<typeof Banner>; - -export const Default: Story = { - args: { - children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - type: 'default', - link: 'https://github.com/openjs-foundation/summit/issues/360', - }, -}; - -export const Error: Story = { - args: { - children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - type: 'error', - link: 'https://github.com/nodejs/nodejs.org/issues/4495', - }, -}; - -export const Warning: Story = { - args: { - children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - type: 'warning', - link: 'https://github.com/nodejs/nodejs.org/issues/4495', - }, -}; - -export const NoLink: Story = { - args: { - children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - type: 'default', - }, -}; - -export default { component: Banner } as Meta; diff --git a/apps/site/components/Common/Banner/index.tsx b/apps/site/components/Common/Banner/index.tsx deleted file mode 100644 index f72b98e15a79e..0000000000000 --- a/apps/site/components/Common/Banner/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; -import type { FC, PropsWithChildren } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type BannerProps = { - link?: string; - type?: 'default' | 'warning' | 'error'; -}; - -const Banner: FC<PropsWithChildren<BannerProps>> = ({ - type = 'default', - link, - children, -}) => ( - <div className={`${styles.banner} ${styles[type] || styles.default}`}> - {link ? <Link href={link}>{children}</Link> : children} - {link && <ArrowUpRightIcon />} - </div> -); - -export default Banner; diff --git a/apps/site/components/Common/Blockquote/index.module.css b/apps/site/components/Common/Blockquote/index.module.css deleted file mode 100644 index 410802f9cb90f..0000000000000 --- a/apps/site/components/Common/Blockquote/index.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.wrapper { - @apply flex - max-w-2xl - flex-col - items-start - gap-4 - self-stretch - border-l-2 - border-green-600 - py-2 - pl-5 - text-lg - font-semibold - text-neutral-900 - dark:border-green-400 - dark:text-white; - - & cite { - @apply font-regular - text-base - not-italic; - - &::before { - @apply content-['—_']; - } - } -} diff --git a/apps/site/components/Common/Blockquote/index.stories.tsx b/apps/site/components/Common/Blockquote/index.stories.tsx deleted file mode 100644 index d00a56c084f33..0000000000000 --- a/apps/site/components/Common/Blockquote/index.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Blockquote from '@/components/Common/Blockquote'; - -type Story = StoryObj<typeof Blockquote>; -type Meta = MetaObj<typeof Blockquote>; - -export const Default: Story = { - args: { - children: ( - <> - “An expert is a person who has made all the mistakes that can be made in - a very narrow field.” - <cite>Niels Bohr</cite> - </> - ), - }, -}; - -export const WithoutAttribution: Story = { - args: { - children: - '“Commander Keen soared through the stars, leaving behind a galaxy of memories, adventure, and boundless imagination. In the realm of gaming, his farewell echoes as a timeless voyage into the hearts of those who dare to dream and explore the unknown.”', - }, -}; - -export const Nested: Story = { - args: { - children: ( - <> - “Words can be like X-rays, if you use them properly—they’ll go through - anything. You read and you’re pierced.” - <Blockquote> - “A thinker sees his own actions as experiments and questions--as - attempts to find out something. Success and failure are for him - answers above all.” - <cite>Friedrich Nietzsche</cite> - </Blockquote> - <cite>Aldous Huxley, Brave New World</cite> - </> - ), - }, -}; - -export default { component: Blockquote } as Meta; diff --git a/apps/site/components/Common/BlogPostCard/__tests__/index.test.mjs b/apps/site/components/Common/BlogPostCard/__tests__/index.test.mjs deleted file mode 100644 index f6a45912ae3a7..0000000000000 --- a/apps/site/components/Common/BlogPostCard/__tests__/index.test.mjs +++ /dev/null @@ -1,119 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import BlogPostCard from '@/components/Common/BlogPostCard'; - -function renderBlogPostCard({ - title = 'Blog post title', - type = 'vulnerability', - description = 'Blog post description', - authors = [], - date = new Date(), - slug = '', -}) { - render( - <BlogPostCard - title={title} - category={type} - description={description} - authors={authors} - date={date} - slug={slug} - /> - ); - - return { title, type, description, authors, date }; -} - -describe('BlogPostCard', () => { - describe('Rendering', () => { - it('Wraps the entire card within an article', () => { - renderBlogPostCard({}); - - expect(screen.getByRole('article')).toBeVisible(); - }); - - it('Renders the title prop correctly', () => { - const { title } = renderBlogPostCard({}); - - expect(screen.getAllByText(title).length).toBe(2); - - // title from preview should be ignored as the one from Links - // and blog card/post are what matter - expect(screen.getAllByText(title)[0]).toHaveAttribute( - 'aria-hidden', - 'true' - ); - }); - - it('Renders the description prop correctly', () => { - const { description } = renderBlogPostCard({}); - - expect(screen.getByText(description)).toBeVisible(); - }); - - it.each([ - { label: 'layouts.blog.categories.vulnerability', type: 'vulnerability' }, - { label: 'layouts.blog.categories.announcements', type: 'announcements' }, - { label: 'layouts.blog.categories.release', type: 'release' }, - ])( - 'Renders "%label" text when passing it the type "%type"', - ({ label, type }) => { - renderBlogPostCard({ type }); - - expect(screen.getByText(label)).toBeVisible(); - } - ); - - it('Renders all passed authors fullName(s), comma-separated', () => { - const authors = [ - { fullName: 'Jane Doe', src: '' }, - { fullName: 'John Doe', src: '' }, - ]; - - renderBlogPostCard({ authors }); - - const fullNames = authors.reduce((prev, curr, index) => { - if (index === 0) { - return curr.fullName; - } - - return `${prev}, ${curr.fullName}`; - }, ''); - - expect(screen.getByText(fullNames)).toBeVisible(); - }); - - it('Renders all passed authors fullName(s), comma-separated', () => { - const authors = [ - { fullName: 'Jane Doe', src: '' }, - { fullName: 'John Doe', src: '' }, - ]; - - renderBlogPostCard({ authors }); - - const fullNames = authors.reduce((prev, curr, index) => { - if (index === 0) { - return curr.fullName; - } - - return `${prev}, ${curr.fullName}`; - }, ''); - - expect(screen.getByText(fullNames)).toBeVisible(); - }); - - it('Renders date prop in short format', () => { - const date = new Date(); - - renderBlogPostCard({ date }); - - const dateTimeFormat = new Intl.DateTimeFormat('en-US', { - day: '2-digit', - month: 'short', - year: 'numeric', - }); - - expect(screen.getByText(dateTimeFormat.format(date))).toBeVisible(); - }); - }); -}); diff --git a/apps/site/components/Common/BlogPostCard/index.module.css b/apps/site/components/Common/BlogPostCard/index.module.css deleted file mode 100644 index a2c84c21e5835..0000000000000 --- a/apps/site/components/Common/BlogPostCard/index.module.css +++ /dev/null @@ -1,51 +0,0 @@ -.container { - @apply max-w-full - flex-1; -} - -.subtitle { - @apply mb-2 - mt-6 - inline-block - text-xs - font-semibold - text-green-600 - dark:text-green-400; -} - -.title { - @apply mb-2 - block - text-xl - font-semibold - text-neutral-900 - dark:text-white; -} - -.description { - @apply mb-6 - line-clamp-3 - text-sm - text-neutral-800 - dark:text-neutral-200; -} - -.footer { - @apply flex - gap-x-3; -} - -.author { - p { - @apply text-sm - font-semibold - text-neutral-900 - dark:text-white; - } - - time { - @apply text-sm - text-neutral-800 - dark:text-neutral-200; - } -} diff --git a/apps/site/components/Common/BlogPostCard/index.stories.tsx b/apps/site/components/Common/BlogPostCard/index.stories.tsx deleted file mode 100644 index 0311c67453386..0000000000000 --- a/apps/site/components/Common/BlogPostCard/index.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import BlogPostCard from '@/components/Common/BlogPostCard'; - -type Story = StoryObj<typeof BlogPostCard>; -type Meta = MetaObj<typeof BlogPostCard>; - -export const Default: Story = { - args: { - title: 'Node.js March 17th Infrastructure Incident Post-mortem', - category: 'vulnerability', - description: - 'Starting on March 15th and going through to March 17th (with much of the issue being mitigated on the 16th), users were receiving intermittent 404 responses when trying to download Node.js from nodejs.org, or even accessing parts of the website.', - authors: [ - { - fullName: 'Hayden Bleasel', - src: 'https://avatars.githubusercontent.com/u/', - }, - ], - slug: '/blog/vulnerability/something', - date: new Date('17 October 2023'), - }, - decorators: [ - Story => ( - <div className="max-w-lg"> - <Story /> - </div> - ), - ], -}; - -export const MoreThanOneAuthor: Story = { - ...Default, - args: { - ...Default.args, - authors: [ - ...(Default.args?.authors ?? []), - { - fullName: 'Jane Doe', - src: 'https://avatars.githubusercontent.com/u/', - }, - ], - }, -}; - -export default { component: BlogPostCard } as Meta; diff --git a/apps/site/components/Common/BlogPostCard/index.tsx b/apps/site/components/Common/BlogPostCard/index.tsx deleted file mode 100644 index 2065f0b20d5d2..0000000000000 --- a/apps/site/components/Common/BlogPostCard/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import AvatarGroup from '@/components/Common/AvatarGroup'; -import FormattedTime from '@/components/Common/FormattedTime'; -import Preview from '@/components/Common/Preview'; -import Link from '@/components/Link'; -import { mapBlogCategoryToPreviewType } from '@/util/blogUtils'; - -import styles from './index.module.css'; - -// @todo: this should probably be a global type? -type Author = { fullName: string; src: string }; - -type BlogPostCardProps = { - title: string; - category: string; - description?: string; - authors?: Array<Author>; - date?: Date; - slug?: string; -}; - -const BlogPostCard: FC<BlogPostCardProps> = ({ - title, - slug, - category, - description, - authors = [], - date, -}) => { - const t = useTranslations(); - - const avatars = authors.map(({ fullName, src }) => ({ alt: fullName, src })); - - const type = mapBlogCategoryToPreviewType(category); - - return ( - <article className={styles.container}> - <Link href={slug} aria-label={title}> - <Preview title={title} type={type} /> - </Link> - - <Link href={`/blog/${category}`} className={styles.subtitle}> - {t(`layouts.blog.categories.${category}`)} - </Link> - - <Link href={slug} className={styles.title}> - {title} - </Link> - - {description && <p className={styles.description}>{description}</p>} - - <footer className={styles.footer}> - <AvatarGroup avatars={avatars ?? []} /> - - <div className={styles.author}> - {avatars && <p>{avatars.map(({ alt }) => alt).join(', ')}</p>} - - {date && <FormattedTime date={date} />} - </div> - </footer> - </article> - ); -}; - -export default BlogPostCard; diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css b/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css deleted file mode 100644 index bb1daa3ce8b60..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.icon { - @apply size-4; -} diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.tsx b/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.tsx deleted file mode 100644 index dc366d30e4f98..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbHomeLink/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import HomeIcon from '@heroicons/react/24/outline/HomeIcon'; -import { useTranslations } from 'next-intl'; -import type { ComponentProps, FC } from 'react'; - -import BreadcrumbLink from '@/components/Common/Breadcrumbs/BreadcrumbLink'; - -import styles from './index.module.css'; - -type BreadcrumbHomeLinkProps = Omit< - ComponentProps<typeof BreadcrumbLink>, - 'href' -> & - Partial<Pick<ComponentProps<typeof BreadcrumbLink>, 'href'>>; - -const BreadcrumbHomeLink: FC<BreadcrumbHomeLinkProps> = ({ - href = '/', - ...props -}) => { - const t = useTranslations(); - - const navigateToHome = t('components.common.breadcrumbs.navigateToHome'); - - return ( - <BreadcrumbLink href={href} aria-label={navigateToHome} {...props}> - <HomeIcon - title={navigateToHome} - aria-label={navigateToHome} - className={styles.icon} - /> - </BreadcrumbLink> - ); -}; - -export default BreadcrumbHomeLink; diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.module.css b/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.module.css deleted file mode 100644 index 8edfa1fb194b1..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.item { - @apply flex - max-w-fit - items-center - gap-5 - truncate - text-sm - font-medium; - - &:last-child { - @apply w-full; - } - - a { - @apply flex-shrink - flex-grow; - } - - &, - > a, - > a:hover { - @apply text-neutral-800 - dark:text-neutral-200; - } - - &.visuallyHidden { - @apply hidden; - } - - .separator { - @apply size-4 - max-w-fit - flex-shrink-0 - flex-grow - text-neutral-600 - dark:text-neutral-400; - } -} diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.tsx b/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.tsx deleted file mode 100644 index f78e3718af617..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbItem/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon'; -import classNames from 'classnames'; -import type { ComponentProps, FC, PropsWithChildren } from 'react'; - -import styles from './index.module.css'; - -type BreadcrumbItemProps = { - disableMicrodata?: boolean; - hidden?: boolean; - hideSeparator?: boolean; - position?: number; -} & ComponentProps<'li'>; - -const BreadcrumbItem: FC<PropsWithChildren<BreadcrumbItemProps>> = ({ - disableMicrodata, - children, - hidden = false, - hideSeparator = false, - position, - ...props -}) => ( - <li - {...props} - itemProp={!disableMicrodata ? 'itemListElement' : undefined} - itemScope={!disableMicrodata ? true : undefined} - itemType={!disableMicrodata ? 'https://schema.org/ListItem' : undefined} - className={classNames( - styles.item, - { [styles.visuallyHidden]: hidden }, - props.className - )} - aria-hidden={hidden ? 'true' : undefined} - > - {children} - {position && <meta itemProp="position" content={`${position}`} />} - {!hideSeparator && ( - <ChevronRightIcon aria-hidden="true" className={styles.separator} /> - )} - </li> -); - -export default BreadcrumbItem; diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.module.css b/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.module.css deleted file mode 100644 index b23a220ff8147..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.link { - @apply max-w-fit - truncate; - - &.active { - @apply rounded - bg-green-600 - px-2 - py-1 - font-semibold - text-white - dark:text-white; - - &:hover { - @apply bg-green-700 - text-white; - } - } -} diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.tsx b/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.tsx deleted file mode 100644 index 93f58ae3a5d96..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbLink/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import classNames from 'classnames'; -import type { ComponentProps, FC } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type BreadcrumbLinkProps = { - active?: boolean; -} & ComponentProps<typeof Link>; - -const BreadcrumbLink: FC<BreadcrumbLinkProps> = ({ - href, - active, - ...props -}) => ( - <Link - itemScope - itemType="http://schema.org/Thing" - itemProp="item" - itemID={href?.toString()} - href={href} - className={classNames( - styles.link, - { [styles.active]: active }, - props.className - )} - aria-current={active ? 'page' : undefined} - {...props} - > - <span itemProp="name">{props.children}</span> - </Link> -); - -export default BreadcrumbLink; diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbRoot/index.module.css b/apps/site/components/Common/Breadcrumbs/BreadcrumbRoot/index.module.css deleted file mode 100644 index a0d9c35c8a58c..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbRoot/index.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.list { - @apply flex - w-full - gap-5 - px-6; -} diff --git a/apps/site/components/Common/Breadcrumbs/BreadcrumbTruncatedItem/index.tsx b/apps/site/components/Common/Breadcrumbs/BreadcrumbTruncatedItem/index.tsx deleted file mode 100644 index 557baee9c1085..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/BreadcrumbTruncatedItem/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import BreadcrumbItem from '@/components/Common/Breadcrumbs/BreadcrumbItem'; - -const BreadcrumbTruncatedItem = () => ( - <BreadcrumbItem disableMicrodata> - <button disabled>â€Ļ</button> - </BreadcrumbItem> -); - -export default BreadcrumbTruncatedItem; diff --git a/apps/site/components/Common/Breadcrumbs/index.stories.tsx b/apps/site/components/Common/Breadcrumbs/index.stories.tsx deleted file mode 100644 index 678def7290c08..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/index.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Breadcrumbs from '@/components/Common/Breadcrumbs'; - -type Story = StoryObj<typeof Breadcrumbs>; -type Meta = MetaObj<typeof Breadcrumbs>; - -export const Default: Story = { - args: { - links: [ - { - label: 'Learn', - href: '/learn', - }, - { - label: 'Getting Started', - href: '/learn/getting-started', - }, - { - label: 'Introduction to Node.js', - href: '/learn/getting-started/intro', - }, - ], - }, -}; - -export const Truncate: Story = { - args: { - links: [ - { - label: 'Learn', - href: '/learn', - }, - { - label: 'Getting Started', - href: '/learn/getting-started', - }, - { - label: 'Introduction to Node.js', - href: '/learn/getting-started/intro', - }, - { - label: 'Installation', - href: '/learn/getting-started/intro/installation', - }, - { - label: 'Documentation', - href: '/learn/getting-started/intro/installation/documentation', - }, - ], - maxLength: 1, - }, -}; - -export const HiddenHome: Story = { - args: { - hideHome: true, - links: [ - { - label: 'Learn', - href: '/learn', - }, - { - label: 'Getting Started', - href: '/learn/getting-started', - }, - { - label: 'Introduction to Node.js', - href: '/learn/getting-started/intro', - }, - ], - }, -}; - -export default { component: Breadcrumbs } as Meta; diff --git a/apps/site/components/Common/Breadcrumbs/index.tsx b/apps/site/components/Common/Breadcrumbs/index.tsx deleted file mode 100644 index 13c796305cb47..0000000000000 --- a/apps/site/components/Common/Breadcrumbs/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { FC } from 'react'; -import { useMemo } from 'react'; - -import BreadcrumbHomeLink from '@/components/Common/Breadcrumbs/BreadcrumbHomeLink'; -import BreadcrumbItem from '@/components/Common/Breadcrumbs/BreadcrumbItem'; -import BreadcrumbLink from '@/components/Common/Breadcrumbs/BreadcrumbLink'; -import BreadcrumbRoot from '@/components/Common/Breadcrumbs/BreadcrumbRoot'; -import BreadcrumbTruncatedItem from '@/components/Common/Breadcrumbs/BreadcrumbTruncatedItem'; -import type { FormattedMessage } from '@/types'; - -export type BreadcrumbLink = { - label: FormattedMessage; - href: string | undefined; -}; - -type BreadcrumbsProps = { - links: Array<BreadcrumbLink>; - maxLength?: number; - hideHome?: boolean; -}; - -const Breadcrumbs: FC<BreadcrumbsProps> = ({ - links = [], - maxLength = 5, - hideHome = false, -}) => { - const totalLength = links.length + +!hideHome; - const lengthOffset = maxLength - totalLength; - const isOverflow = lengthOffset < 0; - - const items = useMemo( - () => - links.map((link, index, items) => { - const position = index + 1; - const isLastItem = index === items.length - 1; - const hidden = - // We add 1 here to take into account of the truncated breadcrumb. - position <= Math.abs(lengthOffset) + 1 && isOverflow && !isLastItem; - - return ( - <BreadcrumbItem - key={link.label.toString()} - hidden={hidden} - hideSeparator={isLastItem} - position={position + +!hideHome} - > - <BreadcrumbLink href={link.href} active={isLastItem}> - {link.label} - </BreadcrumbLink> - </BreadcrumbItem> - ); - }), - [hideHome, isOverflow, lengthOffset, links] - ); - - return ( - <BreadcrumbRoot> - {!hideHome && ( - <BreadcrumbItem position={1}> - <BreadcrumbHomeLink /> - </BreadcrumbItem> - )} - {isOverflow && <BreadcrumbTruncatedItem />} - {items} - </BreadcrumbRoot> - ); -}; - -export default Breadcrumbs; diff --git a/apps/site/components/Common/Button.tsx b/apps/site/components/Common/Button.tsx new file mode 100644 index 0000000000000..80b8bbe73e782 --- /dev/null +++ b/apps/site/components/Common/Button.tsx @@ -0,0 +1,10 @@ +import BaseButton from '@node-core/ui-components/Common/BaseButton'; + +import Link from '#site/components/Link'; + +import type { ButtonProps } from '@node-core/ui-components/Common/BaseButton'; +import type { FC } from 'react'; + +const Button: FC<ButtonProps> = props => <BaseButton as={Link} {...props} />; + +export default Button; diff --git a/apps/site/components/Common/Button/index.module.css b/apps/site/components/Common/Button/index.module.css deleted file mode 100644 index 1c12b1040b718..0000000000000 --- a/apps/site/components/Common/Button/index.module.css +++ /dev/null @@ -1,138 +0,0 @@ -.button { - @apply px-4.5 - relative - inline-flex - items-center - gap-2 - py-2.5 - text-center - font-semibold; - - svg { - @apply size-5; - } - - &[aria-disabled='true'] { - @apply cursor-not-allowed; - } - - &.neutral { - @apply rounded - bg-neutral-900 - text-white - dark:text-neutral-200; - - &:hover:not([aria-disabled='true']) { - @apply bg-neutral-800; - } - - &[aria-disabled='true'] { - @apply bg-neutral-900 - opacity-50; - } - - &:focus { - @apply bg-neutral-800; - } - } - - &.primary { - @apply rounded - border - border-green-600 - bg-green-600 - text-white - shadow-sm; - - &:hover:not([aria-disabled='true']) { - @apply border-green-700 - bg-green-700 - text-white; - } - - &:focus { - @apply border-green-700 - bg-green-700; - } - - &[aria-disabled='true'] { - @apply bg-green-600 - opacity-50; - } - } - - &.secondary { - @apply rounded-lg - text-neutral-800 - dark:text-neutral-200; - - &:hover:not([aria-disabled='true']) { - @apply bg-neutral-100 - text-neutral-800 - dark:bg-neutral-900 - dark:text-neutral-200; - } - - &[aria-disabled='true'] { - @apply bg-transparent - opacity-50; - } - - &:focus { - @apply bg-neutral-100 - text-neutral-800 - dark:bg-neutral-900 - dark:text-neutral-200; - } - } - - &.special { - @apply rounded-lg - border - border-green-600/30 - bg-green-600/10 - text-white - shadow-sm; - - &::before { - @apply bg-gradient-glow-backdrop - absolute - left-0 - right-0 - top-0 - -z-10 - mx-auto - h-full - w-full - opacity-30 - content-['']; - } - - &::after { - @apply absolute - -top-px - left-0 - right-0 - mx-auto - h-px - w-2/5 - bg-gradient-to-r - from-green-600/0 - via-green-600 - to-green-600/0 - content-['']; - } - - &[aria-disabled='true'] { - @apply opacity-50; - } - - &:hover:not([aria-disabled='true']) { - @apply bg-green-600/20; - } - - &:focus { - @apply bg-green-600/20; - } - } -} diff --git a/apps/site/components/Common/Button/index.stories.tsx b/apps/site/components/Common/Button/index.stories.tsx deleted file mode 100644 index d10805dc60e54..0000000000000 --- a/apps/site/components/Common/Button/index.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { ArrowRightIcon } from '@heroicons/react/24/solid'; -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Button from '@/components/Common/Button'; - -type Story = StoryObj<typeof Button>; -type Meta = MetaObj<typeof Button>; - -export const Neutral: Story = { - args: { - kind: 'neutral', - children: 'Download Node (LTS)', - disabled: false, - }, -}; - -export const Primary: Story = { - args: { - kind: 'primary', - children: 'Download Node (LTS)', - disabled: false, - }, -}; - -export const Secondary: Story = { - args: { - kind: 'secondary', - children: 'Download Node (LTS)', - disabled: false, - }, -}; - -export const Special: Story = { - args: { - kind: 'special', - children: 'Download Node (LTS)', - disabled: false, - }, -}; - -export const WithIcon: Story = { - args: { - kind: 'primary', - children: ( - <> - Back to Home - <ArrowRightIcon /> - </> - ), - disabled: false, - }, -}; - -export default { component: Button } as Meta; diff --git a/apps/site/components/Common/Button/index.tsx b/apps/site/components/Common/Button/index.tsx deleted file mode 100644 index cb7f77eed0c89..0000000000000 --- a/apps/site/components/Common/Button/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import classNames from 'classnames'; -import type { FC, AnchorHTMLAttributes } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type ButtonProps = AnchorHTMLAttributes<HTMLAnchorElement> & { - kind?: 'neutral' | 'primary' | 'secondary' | 'special'; - // We have an extra `disabled` prop as we simulate a button - disabled?: boolean; -}; - -const Button: FC<ButtonProps> = ({ - kind = 'primary', - disabled = false, - href = undefined, - children, - className, - ...props -}) => ( - <Link - role="button" - href={disabled ? undefined : href} - aria-disabled={disabled} - className={classNames(styles.button, styles[kind], className)} - {...props} - > - {children} - </Link> -); - -export default Button; diff --git a/apps/site/components/Common/CodeBox.tsx b/apps/site/components/Common/CodeBox.tsx new file mode 100644 index 0000000000000..5ef26c06342b5 --- /dev/null +++ b/apps/site/components/Common/CodeBox.tsx @@ -0,0 +1,28 @@ +'use client'; + +import BaseCodeBox from '@node-core/ui-components/Common/BaseCodeBox'; +import { useTranslations } from 'next-intl'; + +import Link from '#site/components/Link'; + +import type { FC, PropsWithChildren } from 'react'; + +type CodeBoxProps = { + language: string; + className?: string; +}; + +const CodeBox: FC<PropsWithChildren<CodeBoxProps>> = props => { + const t = useTranslations(); + + return ( + <BaseCodeBox + as={Link} + copyButtonLabel={t('components.common.codebox.copy')} + copiedButtonLabel={t('components.common.codebox.copied')} + {...props} + /> + ); +}; + +export default CodeBox; diff --git a/apps/site/components/Common/CodeBox/index.module.css b/apps/site/components/Common/CodeBox/index.module.css deleted file mode 100644 index 929618d231ff2..0000000000000 --- a/apps/site/components/Common/CodeBox/index.module.css +++ /dev/null @@ -1,81 +0,0 @@ -.root { - @apply w-full - rounded - border - border-neutral-900 - bg-neutral-950; - - .content { - @apply m-0 - p-4; - - & > code { - @apply font-ibm-plex-mono - font-regular - grid - overflow-x-auto - bg-transparent - p-0 - text-sm - leading-snug - text-neutral-400 - [counter-reset:line]; - - & > [class='line'] { - @apply relative - min-w-0 - pl-8; - - &:not(:empty:last-child)::before { - @apply inline-block - content-['']; - } - - &:not(:empty:last-child)::after { - @apply w-4.5 - font-ibm-plex-mono - absolute - left-0 - top-0 - mr-4 - text-right - text-neutral-600 - [content:counter(line)] - [counter-increment:line]; - } - } - } - } - - & > .footer { - @apply flex - items-center - justify-between - border-t - border-t-neutral-900 - px-4 - py-3 - text-sm - font-medium; - - & > .language { - @apply text-neutral-400; - } - - & > .action { - @apply px-3 - py-1.5 - font-medium; - } - } -} - -.notification { - @apply flex - items-center - gap-3; -} - -.icon { - @apply size-4; -} diff --git a/apps/site/components/Common/CodeBox/index.stories.tsx b/apps/site/components/Common/CodeBox/index.stories.tsx deleted file mode 100644 index f44b33e17707d..0000000000000 --- a/apps/site/components/Common/CodeBox/index.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import CodeBox from '@/components/Common/CodeBox'; - -type Story = StoryObj<typeof CodeBox>; -type Meta = MetaObj<typeof CodeBox>; - -const content = `const http = require('http'); - -const hostname = '127.0.0.1'; -const port = 3000; - -const server = http.createServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello World'); -}); - -server.listen(port, hostname, () => { - console.log(\`Server running at http://\${hostname}:\${port}/\`); -});`; - -export const Default: Story = { - args: { - language: 'JavaScript (CJS)', - children: <code>{content}</code>, - }, -}; - -export const WithCopyButton: Story = { - args: { - language: 'JavaScript (CJS)', - showCopyButton: true, - children: <code>{content}</code>, - }, -}; - -export default { component: CodeBox } as Meta; diff --git a/apps/site/components/Common/CodeBox/index.tsx b/apps/site/components/Common/CodeBox/index.tsx deleted file mode 100644 index 0b6d97df48ca1..0000000000000 --- a/apps/site/components/Common/CodeBox/index.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import { - DocumentDuplicateIcon, - CodeBracketIcon, -} from '@heroicons/react/24/outline'; -import classNames from 'classnames'; -import { useTranslations } from 'next-intl'; -import type { FC, PropsWithChildren, ReactNode } from 'react'; -import { Fragment, isValidElement, useRef } from 'react'; - -import Button from '@/components/Common/Button'; -import { useCopyToClipboard, useNotification } from '@/hooks'; - -import styles from './index.module.css'; - -// Transforms a code element with plain text content into a more structured -// format for rendering with line numbers -const transformCode = (code: ReactNode, language: string): ReactNode => { - if (!isValidElement(code)) { - // Early return when the `CodeBox` child is not a valid element since the - // type is a ReactNode, and can assume any value - return code; - } - - const content = code.props?.children; - - if (code.type !== 'code' || typeof content !== 'string') { - // There is no need to transform an element that is not a code element or - // a content that is not a string - return code; - } - - // Note that since we use `.split` we will have an extra entry - // being an empty string, so we need to remove it - const lines = content.split('\n'); - - const extraStyle = language.length === 0 ? { fontFamily: 'monospace' } : {}; - - return ( - <code style={extraStyle}> - {lines - .flatMap((line, lineIndex) => { - const columns = line.split(' '); - - return [ - <span key={lineIndex} className="line"> - {columns.map((column, columnIndex) => ( - <Fragment key={columnIndex}> - <span>{column}</span> - {columnIndex < columns.length - 1 && <span> </span>} - </Fragment> - ))} - </span>, - // Add a break line so the text content is formatted correctly - // when copying to clipboard - '\n', - ]; - }) - // Here we remove that empty line from before and - // the last flatMap entry which is an `\n` - .slice(0, -2)} - </code> - ); -}; - -type CodeBoxProps = { - language: string; - showCopyButton?: boolean; - className?: string; -}; - -const CodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({ - children, - language, - showCopyButton = true, - className, -}) => { - const ref = useRef<HTMLPreElement>(null); - - const notify = useNotification(); - const [, copyToClipboard] = useCopyToClipboard(); - const t = useTranslations(); - - const onCopy = async () => { - if (ref.current?.textContent) { - copyToClipboard(ref.current.textContent); - - notify({ - duration: 3000, - message: ( - <div className={styles.notification}> - <CodeBracketIcon className={styles.icon} /> - {t('components.common.codebox.copied')} - </div> - ), - }); - } - }; - - return ( - <div className={styles.root}> - <pre - ref={ref} - className={classNames(styles.content, className)} - tabIndex={0} - dir="ltr" - > - {transformCode(children, language)} - </pre> - - {language && ( - <div className={styles.footer}> - <span className={styles.language}>{language}</span> - - {showCopyButton && ( - <Button kind="neutral" className={styles.action} onClick={onCopy}> - <DocumentDuplicateIcon className={styles.icon} /> - {t('components.common.codebox.copy')} - </Button> - )} - </div> - )} - </div> - ); -}; - -export default CodeBox; diff --git a/apps/site/components/Common/CodeTabs/index.module.css b/apps/site/components/Common/CodeTabs/index.module.css deleted file mode 100644 index e6e0110ad45d1..0000000000000 --- a/apps/site/components/Common/CodeTabs/index.module.css +++ /dev/null @@ -1,53 +0,0 @@ -.root { - > [role='tabpanel'] > :first-child { - @apply rounded-t-none; - } - - > div:nth-of-type(1) { - @apply flex - rounded-t - border-x - border-t - border-neutral-900 - bg-neutral-950 - px-2 - pt-3 - md:px-4; - - .trigger { - @apply border-b - border-b-transparent - px-1 - text-neutral-200; - - &[data-state='active'] { - @apply border-b-green-400 - text-green-400; - } - } - - .link { - @apply hidden - items-center - gap-2 - text-center - text-neutral-200 - lg:flex; - - & > .icon { - @apply size-4 - text-neutral-300; - } - - &:is(:link, :visited) { - &:hover { - @apply text-neutral-400; - - & > .icon { - @apply text-neutral-600; - } - } - } - } - } -} diff --git a/apps/site/components/Common/CodeTabs/index.stories.tsx b/apps/site/components/Common/CodeTabs/index.stories.tsx deleted file mode 100644 index b6a3b9e5f0b9c..0000000000000 --- a/apps/site/components/Common/CodeTabs/index.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import type { FC } from 'react'; - -import CodeBox from '@/components/Common/CodeBox'; -import CodeTabs from '@/components/Common/CodeTabs'; - -type Story = StoryObj<typeof CodeTabs>; -type Meta = MetaObj<typeof CodeTabs>; - -const mjsContent = `import * as http from 'http'; - -const hostname = '127.0.0.1'; -const port = 3000; - -const server = http.createServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello World'); -}); - -server.listen(port, hostname, () => { - console.log(\`Server running at http://\${hostname}:\${port}/\`); -});`; - -const cjsContent = `const http = require('http'); - -const hostname = '127.0.0.1'; -const port = 3000; - -const server = http.createServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello World'); -}); - -server.listen(port, hostname, () => { - console.log(\`Server running at http://\${hostname}:\${port}/\`); -});`; - -const TabsContent: FC = () => ( - <> - <TabsPrimitive.Content key="mjs" value="mjs"> - <CodeBox language="JavaScript (MJS)" showCopyButton> - <code>{mjsContent}</code> - </CodeBox> - </TabsPrimitive.Content> - <TabsPrimitive.Content key="cjs" value="cjs"> - <CodeBox language="JavaScript (CJS)" showCopyButton> - <code>{cjsContent}</code> - </CodeBox> - </TabsPrimitive.Content> - </> -); - -export const Default: Story = {}; - -export const WithMoreOptions: Story = { - args: { - linkUrl: 'https://github.com/nodejs/nodejs.org', - linkText: 'More options', - }, -}; - -export default { - component: CodeTabs, - args: { - children: <TabsContent />, - defaultValue: 'mjs', - tabs: [ - { key: 'mjs', label: 'MJS' }, - { key: 'cjs', label: 'CJS' }, - ], - }, -} as Meta; diff --git a/apps/site/components/Common/CodeTabs/index.tsx b/apps/site/components/Common/CodeTabs/index.tsx deleted file mode 100644 index 614db0017079b..0000000000000 --- a/apps/site/components/Common/CodeTabs/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; -import type { ComponentProps, FC, PropsWithChildren } from 'react'; - -import Tabs from '@/components/Common/Tabs'; -import { Link } from '@/navigation.mjs'; - -import styles from './index.module.css'; - -type CodeTabsProps = Pick< - ComponentProps<typeof Tabs>, - 'tabs' | 'defaultValue' -> & { - linkUrl?: string; - linkText?: string; -}; - -const CodeTabs: FC<PropsWithChildren<CodeTabsProps>> = ({ - children, - linkUrl, - linkText, - ...props -}) => ( - <Tabs - {...props} - className={styles.root} - triggerClassName={styles.trigger} - addons={ - linkUrl && - linkText && ( - <Link className={styles.link} href={linkUrl}> - {linkText} - <ArrowUpRightIcon className={styles.icon} /> - </Link> - ) - } - > - {children} - </Tabs> -); - -export default CodeTabs; diff --git a/apps/site/components/Common/CrossLink.tsx b/apps/site/components/Common/CrossLink.tsx new file mode 100644 index 0000000000000..80fcc42cc732a --- /dev/null +++ b/apps/site/components/Common/CrossLink.tsx @@ -0,0 +1,20 @@ +import BaseCrossLink from '@node-core/ui-components/Common/BaseCrossLink'; +import { useTranslations } from 'next-intl'; + +import Link from '#site/components/Link'; + +import type { CrossLinkProps } from '@node-core/ui-components/Common/BaseCrossLink'; +import type { FC } from 'react'; + +const CrossLink: FC<Omit<CrossLinkProps, 'as' | 'label'>> = props => { + const t = useTranslations(); + return ( + <BaseCrossLink + label={t(`components.common.pagination.${props.type}`)} + as={Link} + {...props} + /> + ); +}; + +export default CrossLink; diff --git a/apps/site/components/Common/CrossLink/index.module.css b/apps/site/components/Common/CrossLink/index.module.css deleted file mode 100644 index 0e2d8d959e1dd..0000000000000 --- a/apps/site/components/Common/CrossLink/index.module.css +++ /dev/null @@ -1,49 +0,0 @@ -.crossLink { - @apply flex - flex-col - items-start - gap-2 - truncate - rounded - border - border-solid - border-neutral-300 - bg-white - p-3 - no-underline - dark:border-neutral-900 - dark:bg-neutral-950; - - .header { - @apply flex - items-center - gap-2 - self-stretch - text-xs - text-neutral-800 - dark:text-neutral-100; - - &.reverse { - @apply flex-row-reverse - justify-start; - } - - .icon { - @apply size-4 - text-neutral-600 - dark:text-neutral-400; - } - } - - .content { - @apply self-stretch - truncate - text-sm - text-neutral-900 - dark:text-white; - - &.reverse { - @apply text-right; - } - } -} diff --git a/apps/site/components/Common/CrossLink/index.stories.tsx b/apps/site/components/Common/CrossLink/index.stories.tsx deleted file mode 100644 index 36ccf37f396dc..0000000000000 --- a/apps/site/components/Common/CrossLink/index.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import CrossLink from '@/components/Common/CrossLink'; - -type Story = StoryObj<typeof CrossLink>; -type Meta = MetaObj<typeof CrossLink>; - -export const Prev: Story = { - args: { - type: 'previous', - text: 'How to install Node.js', - link: 'https://nodejs.dev/en/learn/how-to-install-nodejs/', - }, - decorators: [ - Story => ( - <div className="w-[305px]"> - <Story /> - </div> - ), - ], -}; - -export const Next: Story = { - args: { - type: 'next', - text: 'How much JavaScript do you need to know to use Node.js?', - link: 'https://nodejs.dev/en/learn/how-much-javascript-do-you-need-to-know-to-use-nodejs/', - }, - decorators: [ - Story => ( - <div className="w-[305px]"> - <Story /> - </div> - ), - ], -}; - -export default { component: CrossLink } as Meta; diff --git a/apps/site/components/Common/CrossLink/index.tsx b/apps/site/components/Common/CrossLink/index.tsx deleted file mode 100644 index e96bef0b5a35f..0000000000000 --- a/apps/site/components/Common/CrossLink/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import classNames from 'classnames'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import PrevNextArrow from '@/components/Common/PrevNextArrow'; -import Link from '@/components/Link'; -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type CrossLinkProps = { - type: 'previous' | 'next'; - text: FormattedMessage; - link: string; -}; - -const CrossLink: FC<CrossLinkProps> = ({ type, text, link }) => { - const t = useTranslations(); - - return ( - <Link className={styles.crossLink} href={link}> - <span - className={classNames(styles.header, { - [styles.reverse]: type === 'next', - })} - > - <PrevNextArrow className={styles.icon} type={type} /> - {t(`components.common.crossLink.${type}`)} - </span> - - <span - className={classNames(styles.content, { - [styles.reverse]: type === 'next', - })} - > - {text} - </span> - </Link> - ); -}; - -export default CrossLink; diff --git a/apps/site/components/Common/FormattedTime.tsx b/apps/site/components/Common/FormattedTime.tsx index 68aff9ee78dd3..775fed96e4824 100644 --- a/apps/site/components/Common/FormattedTime.tsx +++ b/apps/site/components/Common/FormattedTime.tsx @@ -1,8 +1,9 @@ -import type { DateTimeFormatOptions } from 'next-intl'; import { useFormatter } from 'next-intl'; -import type { FC } from 'react'; -import { DEFAULT_DATE_FORMAT } from '@/next.calendar.constants.mjs'; +import { DEFAULT_DATE_FORMAT } from '#site/next.calendar.constants.mjs'; + +import type { DateTimeFormatOptions } from 'next-intl'; +import type { FC } from 'react'; type FormattedTimeProps = { date: string | Date; @@ -12,6 +13,8 @@ type FormattedTimeProps = { const FormattedTime: FC<FormattedTimeProps> = ({ date, format }) => { const formatter = useFormatter(); + // "date" is deterministic + const dateObject = new Date(date); return ( diff --git a/apps/site/components/Common/GlowingBackdrop/index.module.css b/apps/site/components/Common/GlowingBackdrop/index.module.css deleted file mode 100644 index a7f2d817c3091..0000000000000 --- a/apps/site/components/Common/GlowingBackdrop/index.module.css +++ /dev/null @@ -1,33 +0,0 @@ -.glowingBackdrop { - @apply absolute - left-0 - top-0 - -z-10 - size-full - opacity-50 - md:opacity-100; - - &::after { - @apply absolute - inset-0 - m-auto - aspect-square - w-[300px] - rounded-full - bg-green-300 - blur-[120px] - content-[''] - dark:bg-green-700; - } - - svg { - @apply absolute - inset-0 - m-auto - aspect-[1.67] - max-w-[1004px] - object-cover - text-neutral-300 - dark:text-neutral-900/75; - } -} diff --git a/apps/site/components/Common/GlowingBackdrop/index.stories.tsx b/apps/site/components/Common/GlowingBackdrop/index.stories.tsx deleted file mode 100644 index 5fdc861abfcc7..0000000000000 --- a/apps/site/components/Common/GlowingBackdrop/index.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import GlowingBackdrop from '@/components/Common/GlowingBackdrop'; - -type Story = StoryObj<typeof GlowingBackdrop>; -type Meta = MetaObj<typeof GlowingBackdrop>; - -export const Default: Story = {}; - -export default { component: GlowingBackdrop } as Meta; diff --git a/apps/site/components/Common/GlowingBackdrop/index.tsx b/apps/site/components/Common/GlowingBackdrop/index.tsx deleted file mode 100644 index e5b10fe51abb0..0000000000000 --- a/apps/site/components/Common/GlowingBackdrop/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { FC } from 'react'; - -import HexagonGrid from '@/components/Icons/HexagonGrid'; - -import styles from './index.module.css'; - -const GlowingBackdrop: FC = () => ( - <div className={styles.glowingBackdrop}> - <HexagonGrid /> - </div> -); - -export default GlowingBackdrop; diff --git a/apps/site/components/Common/LanguageDropDown/index.module.css b/apps/site/components/Common/LanguageDropDown/index.module.css deleted file mode 100644 index 50b8e6b5fb5ed..0000000000000 --- a/apps/site/components/Common/LanguageDropDown/index.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.languageDropdown { - @apply h-9 - w-9 - rounded-md - p-2 - text-neutral-700 - dark:text-neutral-300; - - &:hover { - @apply bg-neutral-100 - dark:bg-neutral-900; - } -} - -.dropDownContent { - @apply max-h-80 - w-48 - overflow-hidden - rounded - border - border-neutral-200 - bg-white - shadow-lg - dark:border-neutral-900 - dark:bg-neutral-950; - - > div { - @apply max-h-80 - w-48 overflow-y-auto; - } -} - -.dropDownItem { - @apply cursor-pointer - px-2.5 - py-1.5 - text-sm - font-medium - text-neutral-800 - outline-none - data-[highlighted]:bg-green-600 - data-[highlighted]:text-white - dark:text-white; -} - -.currentDropDown { - @apply bg-green-600 text-white; -} diff --git a/apps/site/components/Common/LanguageDropDown/index.stories.tsx b/apps/site/components/Common/LanguageDropDown/index.stories.tsx deleted file mode 100644 index 8cb9b3f37462f..0000000000000 --- a/apps/site/components/Common/LanguageDropDown/index.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import LanguageDropDown from '@/components/Common/LanguageDropDown'; - -type Story = StoryObj<typeof LanguageDropDown>; -type Meta = MetaObj<typeof LanguageDropDown>; - -export const Default: Story = { - args: { - availableLanguages: [ - { name: 'English', code: 'en' }, - { name: 'French', code: 'fr' }, - { name: 'Spanish', code: 'es' }, - ], - currentLanguage: 'en', - }, -}; - -export default { component: LanguageDropDown } as Meta; diff --git a/apps/site/components/Common/LanguageDropDown/index.tsx b/apps/site/components/Common/LanguageDropDown/index.tsx deleted file mode 100644 index f8dfc2fbc6618..0000000000000 --- a/apps/site/components/Common/LanguageDropDown/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { LanguageIcon } from '@heroicons/react/24/outline'; -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import classNames from 'classnames'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import type { LocaleConfig } from '@/types'; - -import styles from './index.module.css'; - -type SimpleLocaleConfig = Pick<LocaleConfig, 'name' | 'code'>; - -type LanguageDropDownProps = { - onChange?: (newLocale: SimpleLocaleConfig) => void; - currentLanguage: string; - availableLanguages: Array<SimpleLocaleConfig>; -}; - -const LanguageDropdown: FC<LanguageDropDownProps> = ({ - onChange = () => {}, - currentLanguage, - availableLanguages, -}) => { - const t = useTranslations(); - - const ariaLabel = t('components.common.languageDropdown.label'); - - return ( - <DropdownMenu.Root> - <DropdownMenu.Trigger asChild> - <button className={styles.languageDropdown} aria-label={ariaLabel}> - <LanguageIcon height="20" /> - </button> - </DropdownMenu.Trigger> - - <DropdownMenu.Portal> - <DropdownMenu.Content - align="start" - className={styles.dropDownContent} - sideOffset={5} - > - <div> - {availableLanguages.map(({ name, code }) => ( - <DropdownMenu.Item - key={code} - onClick={() => onChange({ name, code })} - className={classNames(styles.dropDownItem, { - [styles.currentDropDown]: code === currentLanguage, - })} - > - {name} - </DropdownMenu.Item> - ))} - </div> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu.Root> - ); -}; - -export default LanguageDropdown; diff --git a/apps/site/components/Common/LinkTabs.tsx b/apps/site/components/Common/LinkTabs.tsx new file mode 100644 index 0000000000000..8052baabbaced --- /dev/null +++ b/apps/site/components/Common/LinkTabs.tsx @@ -0,0 +1,12 @@ +import BaseLinkTabs from '@node-core/ui-components/Common/BaseLinkTabs'; + +import Link from '#site/components/Link'; + +import type { LinkTabsProps } from '@node-core/ui-components/Common/BaseLinkTabs'; +import type { FC } from 'react'; + +const LinkTabs: FC<Omit<LinkTabsProps, 'as'>> = props => { + return <BaseLinkTabs as={Link} {...props} />; +}; + +export default LinkTabs; diff --git a/apps/site/components/Common/LinkTabs/index.module.css b/apps/site/components/Common/LinkTabs/index.module.css deleted file mode 100644 index c8f766392b409..0000000000000 --- a/apps/site/components/Common/LinkTabs/index.module.css +++ /dev/null @@ -1,41 +0,0 @@ -.tabsList { - @apply font-open-sans - max-xs:hidden - mb-6 - mt-10 - flex - gap-2 - border-b - border-b-neutral-200 - dark:border-b-neutral-800; - - .tabsTrigger { - @apply border-b-2 - border-b-transparent - px-1 - pb-[11px] - text-sm - font-semibold - text-neutral-800 - dark:text-neutral-200; - - &[data-state='active'] { - @apply border-b-green-600 - text-green-600 - dark:border-b-green-400 - dark:text-green-400; - } - } -} - -.tabsSelect { - @apply sm:visible - md:hidden; - - > span { - @apply max-xs:flex - my-6 - hidden - w-full; - } -} diff --git a/apps/site/components/Common/LinkTabs/index.stories.tsx b/apps/site/components/Common/LinkTabs/index.stories.tsx deleted file mode 100644 index a8bf1cef29079..0000000000000 --- a/apps/site/components/Common/LinkTabs/index.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import LinkTabs from '@/components/Common/LinkTabs'; - -type Story = StoryObj<typeof LinkTabs>; -type Meta = MetaObj<typeof LinkTabs>; - -export const Default: Story = { - args: { - label: 'Select Tab', - tabs: [ - { - key: 'package', - label: 'Package Manager', - link: '/package-manager', - }, - { - key: 'prebuilt', - label: 'Prebuilt Installer', - link: '/prebuilt-installer', - }, - { - key: 'source', - label: 'Source Code', - link: '/source-code', - }, - ], - activeTab: 'prebuilt', - children: <p>Tab content goes here</p>, - }, -}; - -export default { component: LinkTabs } as Meta; diff --git a/apps/site/components/Common/LinkTabs/index.tsx b/apps/site/components/Common/LinkTabs/index.tsx deleted file mode 100644 index 5ad243dc847b4..0000000000000 --- a/apps/site/components/Common/LinkTabs/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { FC, PropsWithChildren } from 'react'; - -import Link from '@/components/Link'; -import WithRouterSelect from '@/components/withRouterSelect'; - -import styles from './index.module.css'; - -type LinkTab = { key: string; label: string; link: string }; - -type LinkTabsProps = { - label?: string; - tabs: Array<LinkTab>; - activeTab: string; -}; - -const LinkTabs: FC<PropsWithChildren<LinkTabsProps>> = ({ - tabs, - label, - activeTab, - children, -}) => ( - <> - <div className={styles.tabsList}> - {tabs.map(tab => ( - <Link - key={tab.key} - href={tab.link} - className={styles.tabsTrigger} - data-state={tab.key === activeTab ? 'active' : 'inactive'} - > - {tab.label} - </Link> - ))} - </div> - - <div className={styles.tabsSelect}> - <WithRouterSelect - label={label} - defaultValue={tabs.find(tab => tab.key === activeTab)?.link} - values={tabs.map(tab => ({ label: tab.label, value: tab.link }))} - /> - </div> - - {children} - </> -); - -export default LinkTabs; diff --git a/apps/site/components/Common/LinkWithArrow/index.module.css b/apps/site/components/Common/LinkWithArrow/index.module.css new file mode 100644 index 0000000000000..b523bdb4b0e6b --- /dev/null +++ b/apps/site/components/Common/LinkWithArrow/index.module.css @@ -0,0 +1,16 @@ +@reference "../../../styles/index.css"; + +.icon { + @apply ml-1 + inline + w-3 + fill-neutral-600 + dark:fill-white; +} + +.button { + @apply text-brand-600 + hover:text-brand-900 + dark:text-brand-400 + dark:hover:text-brand-200; +} diff --git a/apps/site/components/Common/LinkWithArrow/index.tsx b/apps/site/components/Common/LinkWithArrow/index.tsx new file mode 100644 index 0000000000000..66d6e8c39613a --- /dev/null +++ b/apps/site/components/Common/LinkWithArrow/index.tsx @@ -0,0 +1,38 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; +import classNames from 'classnames'; + +import Link from '#site/components/Link'; + +import type { + ButtonHTMLAttributes, + ComponentProps, + FC, + PropsWithChildren, +} from 'react'; + +import styles from './index.module.css'; + +type LinkWithArrowProps = + ComponentProps<typeof Link> | ButtonHTMLAttributes<HTMLButtonElement>; + +const LinkWithArrow: FC<PropsWithChildren<LinkWithArrowProps>> = ({ + children, + className, + ...props +}) => + 'href' in props ? ( + <Link {...props} className={className}> + {children} + <ArrowUpRightIcon className={styles.icon} /> + </Link> + ) : ( + <button + className={classNames(className, styles.button)} + {...(props as ButtonHTMLAttributes<HTMLButtonElement>)} + > + {children} + <ArrowUpRightIcon className={styles.icon} /> + </button> + ); + +export default LinkWithArrow; diff --git a/apps/site/components/Common/NodejsLogo/index.module.css b/apps/site/components/Common/NodejsLogo/index.module.css deleted file mode 100644 index 2e74ac633f684..0000000000000 --- a/apps/site/components/Common/NodejsLogo/index.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.nodejsLogo { - @apply h-6 - w-20; -} diff --git a/apps/site/components/Common/NodejsLogo/index.stories.tsx b/apps/site/components/Common/NodejsLogo/index.stories.tsx deleted file mode 100644 index 691b885d1d283..0000000000000 --- a/apps/site/components/Common/NodejsLogo/index.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import NodejsLogo from '@/components/Common/NodejsLogo'; - -type Story = StoryObj<typeof NodejsLogo>; -type Meta = MetaObj<typeof NodejsLogo>; - -export const Default: Story = {}; - -export const WithPride: Story = { - args: { variant: 'pride' }, -}; - -export default { component: NodejsLogo } as Meta; diff --git a/apps/site/components/Common/NodejsLogo/index.tsx b/apps/site/components/Common/NodejsLogo/index.tsx deleted file mode 100644 index 7ca383e10c505..0000000000000 --- a/apps/site/components/Common/NodejsLogo/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { FC } from 'react'; - -import Nodejs from '@/components/Icons/Logos/Nodejs'; -import type { LogoVariant } from '@/types'; - -import style from './index.module.css'; - -type NodejsLogoProps = { - variant?: LogoVariant; -}; - -const NodejsLogo: FC<NodejsLogoProps> = ({ variant = 'default' }) => ( - <Nodejs variant={variant} className={style.nodejsLogo} /> -); - -export default NodejsLogo; diff --git a/apps/site/components/Common/Notification/index.module.css b/apps/site/components/Common/Notification/index.module.css deleted file mode 100644 index 981e1b9d03cb8..0000000000000 --- a/apps/site/components/Common/Notification/index.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.root { - @apply m-6 - rounded - border - border-neutral-200 - bg-white - px-4 - py-3 - shadow-lg - dark:border-neutral-800 - dark:bg-neutral-900; -} - -.message { - @apply font-medium - text-green-600 - dark:text-white; -} diff --git a/apps/site/components/Common/Notification/index.stories.tsx b/apps/site/components/Common/Notification/index.stories.tsx deleted file mode 100644 index 884d0bb441876..0000000000000 --- a/apps/site/components/Common/Notification/index.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { CodeBracketIcon } from '@heroicons/react/24/solid'; -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Notification from '@/components/Common/Notification'; - -type Story = StoryObj<typeof Notification>; -type Meta = MetaObj<typeof Notification>; - -export const Default: Story = { - args: { - open: true, - duration: 5000, - children: 'Copied to clipboard!', - }, -}; - -export const TimedNotification: Story = { - args: { - duration: 5000, - children: 'Copied to clipboard!', - }, -}; - -export const WithJSX: Story = { - args: { - open: true, - children: ( - <div className="flex items-center gap-3"> - <CodeBracketIcon className="h-4 w-4" /> - Copied to clipboard! - </div> - ), - }, -}; - -export default { component: Notification } as Meta; diff --git a/apps/site/components/Common/Notification/index.tsx b/apps/site/components/Common/Notification/index.tsx deleted file mode 100644 index f84eb00373e0d..0000000000000 --- a/apps/site/components/Common/Notification/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as ToastPrimitive from '@radix-ui/react-toast'; -import classNames from 'classnames'; -import type { FC } from 'react'; - -import styles from './index.module.css'; - -type NotificationProps = { - open?: boolean; - duration?: number; - onChange?: (value: boolean) => void; - children?: React.ReactNode; - className?: string; -}; - -const Notification: FC<NotificationProps> = ({ - open, - duration = 5000, - onChange, - children, - className, -}: NotificationProps) => ( - <ToastPrimitive.Root - open={open} - duration={duration} - onOpenChange={onChange} - className={classNames(styles.root, className)} - > - <ToastPrimitive.Title className={styles.message}> - {children} - </ToastPrimitive.Title> - </ToastPrimitive.Root> -); - -export default Notification; diff --git a/apps/site/components/Common/Pagination.tsx b/apps/site/components/Common/Pagination.tsx new file mode 100644 index 0000000000000..5c4f7715d44fc --- /dev/null +++ b/apps/site/components/Common/Pagination.tsx @@ -0,0 +1,31 @@ +import BasePagination from '@node-core/ui-components/Common/BasePagination'; +import { useTranslations } from 'next-intl'; + +import Link from '#site/components/Link'; + +import type { PaginationProps } from '@node-core/ui-components/Common/BasePagination'; +import type { FC } from 'react'; + +const Pagination: FC< + Omit<PaginationProps, 'as' | 'labels' | 'getPageLabel'> +> = props => { + const t = useTranslations(); + return ( + <BasePagination + as={Link} + labels={{ + aria: t('components.common.pagination.defaultLabel'), + previousAriaLabel: t('components.common.pagination.previousAriaLabel'), + previous: t('components.common.pagination.previous'), + nextAriaLabel: t('components.common.pagination.nextAriaLabel'), + next: t('components.common.pagination.next'), + }} + getPageLabel={pageNumber => + t('components.common.pagination.pageLabel', { pageNumber }) + } + {...props} + /> + ); +}; + +export default Pagination; diff --git a/apps/site/components/Common/Pagination/Ellipsis/index.module.css b/apps/site/components/Common/Pagination/Ellipsis/index.module.css deleted file mode 100644 index b4ce7ce86b786..0000000000000 --- a/apps/site/components/Common/Pagination/Ellipsis/index.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.ellipsis { - @apply w-10 - px-3 - py-2.5 - leading-none - text-neutral-800 - dark:text-neutral-200; -} diff --git a/apps/site/components/Common/Pagination/Ellipsis/index.stories.tsx b/apps/site/components/Common/Pagination/Ellipsis/index.stories.tsx deleted file mode 100644 index 0949bcc20cf72..0000000000000 --- a/apps/site/components/Common/Pagination/Ellipsis/index.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Ellipsis from '@/components/Common/Pagination/Ellipsis'; - -type Story = StoryObj<typeof Ellipsis>; -type Meta = MetaObj<typeof Ellipsis>; - -export const Default: Story = {}; - -export default { component: Ellipsis } as Meta; diff --git a/apps/site/components/Common/Pagination/PaginationListItem/__tests__/index.test.mjs b/apps/site/components/Common/Pagination/PaginationListItem/__tests__/index.test.mjs deleted file mode 100644 index 9c0d2b26f8c6e..0000000000000 --- a/apps/site/components/Common/Pagination/PaginationListItem/__tests__/index.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import PaginationListItem from '@/components/Common/Pagination/PaginationListItem'; - -function renderPaginationListItem({ - url, - pageNumber, - currentPage, - totalPages, -}) { - render( - <PaginationListItem - url={url} - pageNumber={pageNumber} - currentPage={currentPage} - totalPages={totalPages} - /> - ); -} - -describe('PaginationListItem', () => { - it('Renders the list item correctly, including the corresponding ARIA attributes', () => { - const pageNumber = 1; - const totalPages = 10; - const url = 'http://'; - - renderPaginationListItem({ - url, - currentPage: 1, - pageNumber, - totalPages, - }); - - const listItem = screen.getByRole('listitem'); - - expect(listItem).toBeVisible(); - expect(listItem).toHaveAttribute('aria-posinset', String(pageNumber)); - expect(listItem).toHaveAttribute('aria-setsize', String(totalPages)); - - expect(screen.getByRole('link')).toHaveAttribute('href', url); - }); - - it('Assigns aria-current="page" attribute to the link when the current page is equal to the page number', () => { - renderPaginationListItem({ - url: 'http://', - currentPage: 1, - pageNumber: 1, - totalPages: 10, - }); - - expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page'); - }); -}); diff --git a/apps/site/components/Common/Pagination/PaginationListItem/index.module.css b/apps/site/components/Common/Pagination/PaginationListItem/index.module.css deleted file mode 100644 index bcc57af3fe933..0000000000000 --- a/apps/site/components/Common/Pagination/PaginationListItem/index.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.listItem, -.listItem:link, -.listItem:active { - @apply aria-current:bg-green-600 - aria-current:text-white - flex - size-10 - items-center - justify-center - rounded - px-3 - py-2.5 - text-neutral-800 - dark:text-neutral-200; - - &:hover { - @apply bg-neutral-100 - text-neutral-800 - dark:bg-neutral-900 - dark:text-neutral-200; - } -} diff --git a/apps/site/components/Common/Pagination/PaginationListItem/index.stories.tsx b/apps/site/components/Common/Pagination/PaginationListItem/index.stories.tsx deleted file mode 100644 index ed4de699b966e..0000000000000 --- a/apps/site/components/Common/Pagination/PaginationListItem/index.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import PaginationListItem from '@/components/Common/Pagination/PaginationListItem'; - -type Story = StoryObj<typeof PaginationListItem>; -type Meta = MetaObj<typeof PaginationListItem>; - -export const Default: Story = { - args: { - url: '#', - pageNumber: 1, - currentPage: 2, - totalPages: 2, - }, - decorators: [ - Story => ( - <ul className="list-none"> - <Story /> - </ul> - ), - ], -}; - -export const CurrentPage: Story = { - args: { - url: '#', - pageNumber: 1, - currentPage: 1, - totalPages: 1, - }, - decorators: [ - Story => ( - <ul className="list-none"> - <Story /> - </ul> - ), - ], -}; - -export default { component: PaginationListItem } as Meta; diff --git a/apps/site/components/Common/Pagination/PaginationListItem/index.tsx b/apps/site/components/Common/Pagination/PaginationListItem/index.tsx deleted file mode 100644 index a1fd525c7a58f..0000000000000 --- a/apps/site/components/Common/Pagination/PaginationListItem/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -export type PaginationListItemProps = { - url: string; - pageNumber: number; - // One-based number of the current page - currentPage: number; - totalPages: number; -}; - -const PaginationListItem: FC<PaginationListItemProps> = ({ - url, - pageNumber, - currentPage, - totalPages, -}) => { - const t = useTranslations(); - - return ( - <li key={pageNumber} aria-setsize={totalPages} aria-posinset={pageNumber}> - <Link - href={url} - aria-label={t('components.common.pagination.pageLabel', { pageNumber })} - className={styles.listItem} - {...(pageNumber === currentPage && { 'aria-current': 'page' })} - > - <span>{pageNumber}</span> - </Link> - </li> - ); -}; - -export default PaginationListItem; diff --git a/apps/site/components/Common/Pagination/__tests__/index.test.mjs b/apps/site/components/Common/Pagination/__tests__/index.test.mjs deleted file mode 100644 index b28e69af50f65..0000000000000 --- a/apps/site/components/Common/Pagination/__tests__/index.test.mjs +++ /dev/null @@ -1,181 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import Pagination from '@/components/Common/Pagination'; - -function renderPagination({ - currentPage = 1, - pages, - currentPageSiblingsCount, -}) { - const parsedPages = new Array(pages) - .fill({ url: 'page' }) - .map(item => ({ url: `${item.url}-${Math.random()}` })); - - render( - <Pagination - currentPage={currentPage} - pages={parsedPages} - currentPageSiblingsCount={currentPageSiblingsCount} - /> - ); - - return { - currentPage, - parsedPages, - }; -} - -describe('Pagination', () => { - describe('Rendering', () => { - it('Renders the navigation buttons even if no pages are passed to it', () => { - renderPagination({ currentPage: 0, pages: 0 }); - - expect(screen.getByRole('navigation')).toBeVisible(); - - expect( - screen.getByRole('button', { - name: 'components.common.pagination.prevAriaLabel', - }) - ).toBeVisible(); - - expect( - screen.getByRole('button', { - name: 'components.common.pagination.nextAriaLabel', - }) - ).toBeVisible(); - }); - - it('Renders the passed pages and current page', () => { - const { currentPage, parsedPages } = renderPagination({ - pages: 4, - }); - - const pageElements = screen.getAllByRole('link'); - - expect(pageElements).toHaveLength(parsedPages.length); - - pageElements.forEach((page, index) => { - if (index + 1 === currentPage) { - expect(page).toHaveAttribute('aria-current', 'page'); - } - - expect(page).toBeVisible(); - }); - }); - }); - - describe('Ellipsis behavior', () => { - it('When the pages size is equal or smaller than currentPageSiblingsCount + 5, all pages are shown', () => { - renderPagination({ - pages: 6, - }); - - expect(screen.queryByText('...')).not.toBeInTheDocument(); - - const pageElements = screen.getAllByRole('link'); - - expect(pageElements).toHaveLength(6); - - expect(pageElements.map(element => element.textContent)).toEqual([ - '1', - '2', - '3', - '4', - '5', - '6', - ]); - }); - - it('Shows left ellipsis when the left sibling of the current page is at least two pages away from the first page', () => { - renderPagination({ - currentPage: 5, - pages: 8, - }); - - expect(screen.getByText('...')).toBeVisible(); - - const pageElements = screen.getAllByRole('link'); - - expect(pageElements).toHaveLength(6); - - expect(pageElements.map(element => element.textContent)).toEqual([ - '1', - '4', - '5', - '6', - '7', - '8', - ]); - }); - - it('Shows right ellipsis when the right sibling of the current page is at least two pages away from the last page', () => { - renderPagination({ - currentPage: 3, - pages: 8, - }); - - expect(screen.getByText('...')).toBeVisible(); - - const pageElements = screen.getAllByRole('link'); - - expect(pageElements).toHaveLength(6); - - expect(pageElements.map(element => element.textContent)).toEqual([ - '1', - '2', - '3', - '4', - '5', - '8', - ]); - }); - - it('Shows right and left ellipses when the current page siblings are both at least two pages away from the first and last pages', () => { - renderPagination({ - currentPage: 5, - pages: 10, - }); - - expect(screen.getAllByText('...')).toHaveLength(2); - - const pageElements = screen.getAllByRole('link'); - - expect(pageElements).toHaveLength(5); - - expect(pageElements.map(element => element.textContent)).toEqual([ - '1', - '4', - '5', - '6', - '10', - ]); - }); - }); - - describe('Navigation buttons', () => { - it('Disables "Previous" button when the currentPage is equal to the first page', () => { - renderPagination({ - pages: 2, - }); - - expect( - screen.getByRole('button', { - name: 'components.common.pagination.prevAriaLabel', - }) - ).toHaveAttribute('aria-disabled'); - }); - - it('Disables "Next" button when the currentPage is equal to the last page', () => { - renderPagination({ - currentPage: 2, - pages: 2, - }); - - expect( - screen.getByRole('button', { - name: 'components.common.pagination.nextAriaLabel', - }) - ).toHaveAttribute('aria-disabled'); - }); - }); -}); diff --git a/apps/site/components/Common/Pagination/index.module.css b/apps/site/components/Common/Pagination/index.module.css deleted file mode 100644 index 9a7b5936e7e98..0000000000000 --- a/apps/site/components/Common/Pagination/index.module.css +++ /dev/null @@ -1,37 +0,0 @@ -.pagination { - @apply grid - items-center - justify-between - gap-y-5 - [grid-template-areas:'pages_pages_pages''prev_._next'] - md:gap-y-0 - md:[grid-template-areas:'prev_pages_next']; -} - -.previousButton, -.nextButton { - @apply text-sm; -} - -.previousButton { - @apply [grid-area:prev]; -} - -.nextButton { - @apply [grid-area:next]; -} - -.arrowIcon { - @apply h-5 - shrink-0 - text-neutral-600 - dark:text-neutral-400; -} - -.list { - @apply flex - list-none - justify-center - gap-1 - [grid-area:pages]; -} diff --git a/apps/site/components/Common/Pagination/index.stories.tsx b/apps/site/components/Common/Pagination/index.stories.tsx deleted file mode 100644 index 2fc4287fea3c3..0000000000000 --- a/apps/site/components/Common/Pagination/index.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Pagination from '@/components/Common/Pagination'; - -type Story = StoryObj<typeof Pagination>; -type Meta = MetaObj<typeof Pagination>; - -export const Default: Story = { - args: { - currentPage: 1, - currentPageSiblingsCount: 1, - pages: [{ url: '1' }, { url: '2' }, { url: '3' }], - }, -}; - -export const LeftEllipsis: Story = { - args: { - ...Default.args, - currentPage: 5, - pages: [ - { url: '1' }, - { url: '2' }, - { url: '3' }, - { url: '4' }, - { url: '5' }, - { url: '6' }, - { url: '7' }, - { url: '8' }, - ], - }, -}; - -export const RightEllipsis: Story = { - args: { - ...LeftEllipsis.args, - currentPage: 3, - }, -}; - -export const TwoEllipses: Story = { - args: { - ...Default.args, - currentPage: 5, - pages: [ - { url: '1' }, - { url: '2' }, - { url: '3' }, - { url: '4' }, - { url: '5' }, - { url: '6' }, - { url: '7' }, - { url: '8' }, - { url: '9' }, - { url: '10' }, - ], - }, -}; - -export default { component: Pagination } as Meta; diff --git a/apps/site/components/Common/Pagination/index.tsx b/apps/site/components/Common/Pagination/index.tsx deleted file mode 100644 index 969cf4dc86fc3..0000000000000 --- a/apps/site/components/Common/Pagination/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ArrowRightIcon, ArrowLeftIcon } from '@heroicons/react/20/solid'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import Button from '@/components/Common/Button'; -import { useGetPageElements } from '@/components/Common/Pagination/useGetPageElements'; - -import styles from './index.module.css'; - -type Page = { url: string }; - -type PaginationProps = { - // One-based number of the current page - currentPage: number; - pages: Array<Page>; - // The number of page buttons on each side of the current page button - // @default 1 - currentPageSiblingsCount?: number; -}; - -const Pagination: FC<PaginationProps> = ({ - currentPage, - pages, - currentPageSiblingsCount = 1, -}) => { - const t = useTranslations(); - - const parsedPages = useGetPageElements( - currentPage, - pages, - currentPageSiblingsCount - ); - - return ( - <nav - aria-label={t('components.common.pagination.defaultLabel')} - className={styles.pagination} - > - <Button - aria-label={t('components.common.pagination.prevAriaLabel')} - disabled={currentPage === 1} - kind="secondary" - className={styles.previousButton} - href={pages[currentPage - 2]?.url} - > - <ArrowLeftIcon className={styles.arrowIcon} /> - <span>{t('components.common.pagination.prev')}</span> - </Button> - - <ol className={styles.list}>{parsedPages}</ol> - - <Button - aria-label={t('components.common.pagination.nextAriaLabel')} - disabled={currentPage === pages.length} - kind="secondary" - className={styles.nextButton} - href={pages[currentPage]?.url} - > - <span>{t('components.common.pagination.next')}</span> - <ArrowRightIcon className={styles.arrowIcon} /> - </Button> - </nav> - ); -}; - -export default Pagination; diff --git a/apps/site/components/Common/Pagination/useGetPageElements.tsx b/apps/site/components/Common/Pagination/useGetPageElements.tsx deleted file mode 100644 index 9ea65422e02ac..0000000000000 --- a/apps/site/components/Common/Pagination/useGetPageElements.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import type { ComponentProps } from 'react'; - -import type Pagination from '@/components/Common/Pagination'; -import Ellipsis from '@/components/Common/Pagination/Ellipsis'; -import type { PaginationListItemProps } from '@/components/Common/Pagination/PaginationListItem/index'; -import PaginationListItem from '@/components/Common/Pagination/PaginationListItem/index'; - -const parsePages = ( - pages: ComponentProps<typeof Pagination>['pages'], - currentPage: number, - totalPages: number -): Array<PaginationListItemProps> => - pages.map(({ url }, index) => ({ - url, - pageNumber: index + 1, - currentPage, - totalPages, - })); - -const createPaginationListItems = ( - parsedPages: Array<PaginationListItemProps> -) => parsedPages.map(page => <PaginationListItem key={page.url} {...page} />); - -// The minimum amount of elements are first page, current page, and last page -const MINIMUM_AMOUNT_OF_ELEMENTS = 3; - -// Not more than two ellipses will be shown at the same time -const MAXIMUM_AMOUNT_OF_ELLIPSES = 2; - -// The logic of this custom hook has taken the internal logic of -// React MUI's Pagination component as reference. More info here: -// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/usePagination/usePagination.js -export const useGetPageElements = ( - currentPage: ComponentProps<typeof Pagination>['currentPage'], - pages: ComponentProps<typeof Pagination>['pages'], - currentPageSiblingsCount: number -) => { - const totalPages = pages.length; - const parsedPages = parsePages(pages, currentPage, totalPages); - const currentPageIndex = currentPage - 1; - - // We multiply it by two (2) as siblings are located on both left and right sides - // of the current page - const totalSiblingsCount = 2 * currentPageSiblingsCount; - - const visiblePages = - totalSiblingsCount + - MINIMUM_AMOUNT_OF_ELEMENTS + - MAXIMUM_AMOUNT_OF_ELLIPSES; - - // When there are more pages than the visible pages to be shown - // we do not need to perform any calculations - if (totalPages <= visiblePages) { - return createPaginationListItems(parsedPages); - } - - // The index of the far-left sibling of the current page - const leftSiblingsFirstIndex = Math.max( - currentPageIndex - currentPageSiblingsCount, - 1 - ); - - // The index of the far-right sibling of the current page - const rightSiblingsLastIndex = Math.min( - currentPageIndex + currentPageSiblingsCount + 1, - totalPages - ); - - const firstIndex = 0; - const lastIndex = totalPages - 1; - - // If there are at least two (2) elements between the far-left sibling of - // the current page, and the first page, we should show left ellipsis - // between them - const hasLeftEllipsis = leftSiblingsFirstIndex > firstIndex + 2; - - // If there are at least two (2) elements between the far-right sibling of - // the current page, and the last page, we should show right ellipsis - // between them - const hasRightEllipsis = rightSiblingsLastIndex < lastIndex - 1; - - if (!hasLeftEllipsis && hasRightEllipsis) { - const leftPagesLastIndex = MINIMUM_AMOUNT_OF_ELEMENTS + totalSiblingsCount; - - const leftPages = parsedPages.slice(firstIndex, leftPagesLastIndex); - - return [ - ...createPaginationListItems(leftPages), - <Ellipsis key="ellipsis" />, - ...createPaginationListItems(parsedPages.slice(lastIndex)), - ]; - } - - if (hasLeftEllipsis && !hasRightEllipsis) { - const rightPagesFirstIndex = - MINIMUM_AMOUNT_OF_ELEMENTS + totalSiblingsCount; - - const rightPages = parsedPages.slice(totalPages - rightPagesFirstIndex); - - return [ - ...createPaginationListItems( - parsedPages.slice(firstIndex, firstIndex + 1) - ), - <Ellipsis key="ellipsis" />, - ...createPaginationListItems(rightPages), - ]; - } - - if (hasLeftEllipsis && hasRightEllipsis) { - const middlePages = parsedPages.slice( - leftSiblingsFirstIndex, - rightSiblingsLastIndex - ); - - return [ - ...createPaginationListItems( - parsedPages.slice(firstIndex, firstIndex + 1) - ), - <Ellipsis key="ellipsis-1" />, - ...createPaginationListItems(middlePages), - <Ellipsis key="ellipsis-2" />, - ...createPaginationListItems(parsedPages.slice(lastIndex)), - ]; - } -}; diff --git a/apps/site/components/Common/Partners/PartnerButton/index.module.css b/apps/site/components/Common/Partners/PartnerButton/index.module.css new file mode 100644 index 0000000000000..9477a4286b4b4 --- /dev/null +++ b/apps/site/components/Common/Partners/PartnerButton/index.module.css @@ -0,0 +1,31 @@ +@reference "../../../../styles/index.css"; + +.large { + @apply flex + h-28 + max-h-28 + w-auto + min-w-12 + items-center + justify-center + rounded-lg + p-6 + sm:p-10; + + svg { + @apply !h-12 + !w-auto; + } +} + +.small { + @apply h-9 + w-auto + min-w-9; + + svg { + @apply h-4 + w-auto + max-w-4; + } +} diff --git a/apps/site/components/Common/Partners/PartnerButton/index.tsx b/apps/site/components/Common/Partners/PartnerButton/index.tsx new file mode 100644 index 0000000000000..760cb96cb0b4f --- /dev/null +++ b/apps/site/components/Common/Partners/PartnerButton/index.tsx @@ -0,0 +1,26 @@ +import Button from '#site/components/Common/Button'; + +import type { AnchorHTMLAttributes, FC } from 'react'; + +import style from './index.module.css'; + +type PartnerProps = AnchorHTMLAttributes<HTMLAnchorElement> & { + size?: 'large' | 'small'; +}; + +const PartnerButton: FC<PartnerProps> = ({ + href, + size = 'small', + ...props +}) => ( + <Button + kind="secondary" + href={`${href}/?utm_source=nodejs-website&utm_medium=Link`} + rel="sponsored noopener" + target="_blank" + className={style[size]} + {...props} + /> +); + +export default PartnerButton; diff --git a/apps/site/components/Common/Partners/index.module.css b/apps/site/components/Common/Partners/index.module.css new file mode 100644 index 0000000000000..a293da7dd8feb --- /dev/null +++ b/apps/site/components/Common/Partners/index.module.css @@ -0,0 +1,21 @@ +@reference "../../../styles/index.css"; + +.small { + @apply flex + flex-row + flex-wrap + items-center + justify-center + gap-2; +} + +.large { + @apply grid + w-full + gap-6 + sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] + sm:gap-4 + lg:grid-cols-[repeat(auto-fit,minmax(240px,260px))] + lg:justify-center + lg:gap-6; +} diff --git a/apps/site/components/Common/Partners/index.tsx b/apps/site/components/Common/Partners/index.tsx new file mode 100644 index 0000000000000..949c47d8ec81a --- /dev/null +++ b/apps/site/components/Common/Partners/index.tsx @@ -0,0 +1,88 @@ +import * as PartnerLogos from '@node-core/ui-components/Icons/PartnerLogos'; + +import providePartners from '#site/next-data/providers/partners'; +import { partners } from '#site/next.json.mjs'; + +import type { Partner, PartnerCategory } from '#site/types'; +import type { FC } from 'react'; + +import PartnerButton from './PartnerButton'; + +import style from './index.module.css'; + +type PartnersListProps = { + size?: 'large' | 'small'; + category?: PartnerCategory; + sort?: 'name' | 'weight'; + length?: number; +}; + +const getPartners = async ( + length?: number, + category?: PartnerCategory, + sort?: 'name' | 'weight' +) => { + let result = sort === 'name' ? partners : await providePartners(); + + result = category + ? result.filter(p => p.categories.includes(category)) + : result; + + return length ? result.slice(0, length) : result; +}; + +const renderSmallPartner = (partner: Partner) => { + const Logo = PartnerLogos[partner.id]; + + return ( + <PartnerButton + aria-label={partner.name} + key={partner.id} + size="small" + href={partner.href} + data-tooltip={partner.name} + > + <Logo.Favicon /> + </PartnerButton> + ); +}; + +const renderLargePartner = (partner: Partner) => { + const Logo = PartnerLogos[partner.id]; + + return ( + <PartnerButton + aria-label={partner.name} + key={partner.id} + size="large" + href={partner.href} + > + <Logo.Logo /> + </PartnerButton> + ); +}; + +const PartnersList: FC<PartnersListProps> = async ({ + size = 'small', + category, + sort = 'name', + length, +}) => { + const isSmall = size === 'small'; + + const SMALL_PARTNER_LIMIT = 12; + + const partners = await getPartners( + length ?? (isSmall ? SMALL_PARTNER_LIMIT : undefined), + category, + sort + ); + + return ( + <div className={style[size]}> + {partners.map(isSmall ? renderSmallPartner : renderLargePartner)} + </div> + ); +}; + +export default PartnersList; diff --git a/apps/site/components/Common/Preview/index.module.css b/apps/site/components/Common/Preview/index.module.css deleted file mode 100644 index d2a68ff0df3fb..0000000000000 --- a/apps/site/components/Common/Preview/index.module.css +++ /dev/null @@ -1,81 +0,0 @@ -.root { - @apply @container/preview - relative - flex - aspect-[1.90/1] - items-center - rounded - border - border-neutral-900 - bg-neutral-950; - - &::after { - @apply bg-gradient-radial - @md/preview:blur-3xl - absolute - inset-0 - m-auto - aspect-square - w-1/3 - rounded-full - blur-2xl - content-['']; - - &.announcements { - @apply from-green-700/90; - } - - &.release { - @apply from-info-600/90; - } - - &.vulnerability { - @apply from-warning-600/90; - } - } - - .container { - @apply @sm/preview:text-base - @md/preview:gap-6 - @md/preview:text-lg - @lg/preview:gap-8 - @lg/preview:text-xl - @xl/preview:gap-12 - @xl/preview:text-2xl - @2xl/preview:text-3xl - z-10 - mx-auto - flex - w-2/3 - max-w-xl - flex-col - gap-4 - text-center - text-xs - font-semibold - text-white; - - .hexagon { - @apply @md/preview:h-3/5 - @md/preview:w-3/5 - @lg/preview:h-2/3 - @lg/preview:w-2/3 - @xl/preview:h-3/5 - @xl/preview:w-3/5 - @2xl/preview:h-2/3 - @2xl/preview:w-2/3 - absolute - inset-0 - m-auto - size-full; - } - - .logo { - @apply @md/preview:size-14 - @lg/preview:size-16 - @xl/preview:size-20 - mx-auto - size-6; - } - } -} diff --git a/apps/site/components/Common/Preview/index.stories.tsx b/apps/site/components/Common/Preview/index.stories.tsx deleted file mode 100644 index 602ee26f11c26..0000000000000 --- a/apps/site/components/Common/Preview/index.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Preview from '@/components/Common/Preview'; - -type Story = StoryObj<typeof Preview>; -type Meta = MetaObj<typeof Preview>; - -export const Announcement: Story = { - args: { - type: 'announcements', - title: - 'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023', - }, -}; - -export const Release: Story = { - args: { - type: 'release', - title: 'Node v20.5.0 (Current)', - }, -}; - -export const Vulnerability: Story = { - args: { - type: 'vulnerability', - title: 'OpenSSL update assessment, and Node.js project plans', - }, -}; - -export const CustomSize: Story = { - args: { - title: - 'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023', - }, - decorators: [ - Story => ( - <div className="w-[600px]"> - <Story /> - </div> - ), - ], -}; - -export default { component: Preview } as Meta; diff --git a/apps/site/components/Common/Preview/index.tsx b/apps/site/components/Common/Preview/index.tsx deleted file mode 100644 index 489fdc01b18a4..0000000000000 --- a/apps/site/components/Common/Preview/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import classNames from 'classnames'; -import type { FC } from 'react'; - -import HexagonGrid from '@/components/Icons/HexagonGrid'; -import JsIconWhite from '@/components/Icons/Logos/JsIconWhite'; -import type { BlogPreviewType } from '@/types'; - -import styles from './index.module.css'; - -type PreviewProps = { - title: string; - type?: BlogPreviewType; -}; - -const Preview: FC<PreviewProps> = ({ type = 'announcements', title }) => ( - <div className={classNames(styles.root, styles[type])}> - <div className={styles.container} aria-hidden={true}> - <HexagonGrid className={styles.hexagon} /> - <JsIconWhite className={styles.logo} /> - {title} - </div> - </div> -); - -export default Preview; diff --git a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.module.css b/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.module.css deleted file mode 100644 index b9a9678173251..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.module.css +++ /dev/null @@ -1,53 +0,0 @@ -.group { - @apply flex - flex-col - gap-4 - text-sm - font-medium - text-neutral-800 - dark:text-neutral-200; - - .items { - @apply relative - -left-1 - flex - flex-col - gap-2; - - &::after { - @apply absolute - left-[0.45rem] - top-0 - z-10 - h-full - w-px - bg-neutral-200 - content-[''] - dark:bg-neutral-800; - } - - a { - &:first-child::before { - @apply absolute - bottom-[calc(50%+0.25rem)] - left-0 - h-20 - w-4 - bg-white - content-[''] - dark:bg-neutral-950; - } - - &:last-child::after { - @apply absolute - left-0 - top-[calc(50%+0.25rem)] - h-20 - w-4 - bg-white - content-[''] - dark:bg-neutral-950; - } - } - } -} diff --git a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.tsx b/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.tsx deleted file mode 100644 index 0a64e67aa3f08..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarGroup/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { ComponentProps, FC } from 'react'; - -import ProgressionSidebarItem from '@/components/Common/ProgressionSidebar/ProgressionSidebarItem'; -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type ProgressionSidebarGroupProps = { - groupName: FormattedMessage; - items: Array<ComponentProps<typeof ProgressionSidebarItem>>; -}; - -const ProgressionSidebarGroup: FC<ProgressionSidebarGroupProps> = ({ - groupName, - items, -}) => ( - <section className={styles.group}> - {groupName} - <div className={styles.items}> - {items.map(({ label, link }) => ( - <ProgressionSidebarItem key={link} label={label} link={link} /> - ))} - </div> - </section> -); - -export default ProgressionSidebarGroup; diff --git a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarIcon/index.tsx b/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarIcon/index.tsx deleted file mode 100644 index 4c89095de5504..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarIcon/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { FC, SVGAttributes } from 'react'; - -const ProgressionSidebarIcon: FC<SVGAttributes<SVGSVGElement>> = props => ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="16" - height="18" - viewBox="0 0 16 18" - fill="none" - {...props} - > - <path d="M9 3.26795L8 2.6906L7 3.26795L3.5359 5.26795L2.5359 5.8453V7V11V12.1547L3.5359 12.7321L7 14.7321L8 15.3094L9 14.7321L12.4641 12.7321L13.4641 12.1547V11V7V5.8453L12.4641 5.26795L9 3.26795Z" /> - </svg> -); - -export default ProgressionSidebarIcon; diff --git a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.module.css b/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.module.css deleted file mode 100644 index 60342f4abb796..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.module.css +++ /dev/null @@ -1,36 +0,0 @@ -a.item { - @apply font-regular - relative - z-20 - flex - w-full - items-center - gap-1 - overflow-hidden - text-sm - text-neutral-800 - dark:text-neutral-200; - - &:hover { - @apply text-neutral-900 - dark:text-white; - } - - svg { - @apply flex-shrink-0 - fill-neutral-200 - stroke-white - stroke-[4] - dark:fill-neutral-800 - dark:stroke-neutral-950; - } - - &.active { - @apply text-neutral-900 - dark:text-white; - - svg { - @apply fill-green-500; - } - } -} diff --git a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.tsx b/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.tsx deleted file mode 100644 index 047beac8d7326..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/ProgressionSidebarItem/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { FC } from 'react'; - -import ActiveLink from '@/components/Common/ActiveLink'; -import ProgressionSidebarIcon from '@/components/Common/ProgressionSidebar/ProgressionSidebarIcon'; -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type ProgressionSidebarItemProps = { - label: FormattedMessage; - link: string; -}; - -const ProgressionSidebarItem: FC<ProgressionSidebarItemProps> = ({ - label, - link, -}) => ( - <ActiveLink - className={styles.item} - activeClassName={styles.active} - href={link} - > - <ProgressionSidebarIcon /> - {label} - </ActiveLink> -); - -export default ProgressionSidebarItem; diff --git a/apps/site/components/Common/ProgressionSidebar/index.module.css b/apps/site/components/Common/ProgressionSidebar/index.module.css deleted file mode 100644 index c986e4572678c..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/index.module.css +++ /dev/null @@ -1,28 +0,0 @@ -.wrapper { - @apply flex - w-full - flex-col - gap-8 - overflow-auto - border-r-0 - border-neutral-200 - bg-white - px-4 - py-6 - sm:border-r - md:max-w-xs - lg:px-6 - dark:border-neutral-900 - dark:bg-neutral-950; - - > section { - @apply hidden - sm:flex; - } - - > span { - @apply flex - w-full - sm:hidden; - } -} diff --git a/apps/site/components/Common/ProgressionSidebar/index.stories.tsx b/apps/site/components/Common/ProgressionSidebar/index.stories.tsx deleted file mode 100644 index cff59dc659816..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/index.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import ProgressionSidebar from '@/components/Common/ProgressionSidebar'; - -type Story = StoryObj<typeof ProgressionSidebar>; -type Meta = MetaObj<typeof ProgressionSidebar>; - -export const Default: Story = { - args: { - groups: [ - { - groupName: 'Getting Started', - items: [ - { - label: 'Introduction to Node.js', - link: '/', - }, - { - label: 'How to install Node.js', - link: '/how-to-install-nodejs', - }, - { - label: 'How much JavaScript do you need to know to use Node.js?', - link: '/how-much-javascript-do-you-need-to-know-to-use-nodejs', - }, - { - label: 'Differences between Node.js and the Browser', - link: '/differences-between-nodejs-and-the-browser', - }, - { - label: 'The V8 JavaScript Engine', - link: '/the-v8-javascript-engine', - }, - { - label: 'An introduction to the npm package manager', - link: '/an-introduction-to-the-npm-package-manager', - }, - ], - }, - { - groupName: 'Asynchronous Work', - items: [ - { - label: 'Asynchronous flow control', - link: '/asynchronous-flow-control', - }, - { - label: 'Overview of Blocking vs Non-Blocking', - link: '/overview-of-blocking-vs-non-blocking', - }, - ], - }, - { - groupName: 'Manipulating Files', - items: [ - { - label: 'Node.js file stats', - link: '/nodejs-file-stats', - }, - { - label: 'Node.js File Paths', - link: '/nodejs-file-paths', - }, - ], - }, - { - groupName: 'Single item', - items: [ - { - label: 'Item', - link: '/item', - }, - ], - }, - ], - }, -}; - -export default { component: ProgressionSidebar } as Meta; diff --git a/apps/site/components/Common/ProgressionSidebar/index.tsx b/apps/site/components/Common/ProgressionSidebar/index.tsx deleted file mode 100644 index fde4076f4fcb9..0000000000000 --- a/apps/site/components/Common/ProgressionSidebar/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { ComponentProps, FC } from 'react'; -import { useRef } from 'react'; - -import ProgressionSidebarGroup from '@/components/Common/ProgressionSidebar/ProgressionSidebarGroup'; -import WithRouterSelect from '@/components/withRouterSelect'; -import { useClientContext, useNavigationState } from '@/hooks'; - -import styles from './index.module.css'; - -type ProgressionSidebarProps = { - groups: Array<ComponentProps<typeof ProgressionSidebarGroup>>; -}; - -const ProgressionSidebar: FC<ProgressionSidebarProps> = ({ groups }) => { - const t = useTranslations(); - const { pathname } = useClientContext(); - const ref = useRef<HTMLElement>(null); - - useNavigationState('progressionSidebar', ref); - - const selectItems = groups.map(({ items, groupName }) => ({ - label: groupName, - items: items.map(({ label, link }) => ({ value: link, label })), - })); - - const currentItem = selectItems - .map(item => item.items) - .flat() - .find(item => pathname === item.value); - - return ( - <nav className={styles.wrapper} ref={ref}> - <WithRouterSelect - label={t('components.common.sidebar.title')} - values={selectItems} - defaultValue={currentItem?.value} - /> - - {groups.map(({ groupName, items }) => ( - <ProgressionSidebarGroup - key={groupName.toString()} - groupName={groupName} - items={items} - /> - ))} - </nav> - ); -}; - -export default ProgressionSidebar; diff --git a/apps/site/components/Common/Search/States/WithAllResults.tsx b/apps/site/components/Common/Search/States/WithAllResults.tsx deleted file mode 100644 index 481edb8cc3966..0000000000000 --- a/apps/site/components/Common/Search/States/WithAllResults.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Results } from '@orama/orama'; -import NextLink from 'next/link'; -import { useParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import type { SearchDoc } from '@/types'; - -import styles from './index.module.css'; - -type SearchResults = Results<SearchDoc>; - -type SeeAllProps = { - searchResults: SearchResults; - searchTerm: string; - selectedFacetName: string; - onSeeAllClick: () => void; -}; - -export const WithAllResults: FC<SeeAllProps> = props => { - const t = useTranslations(); - const params = useParams(); - - const locale = params?.locale ?? 'en'; - const resultsCount = props.searchResults?.count?.toLocaleString('en') ?? 0; - const searchParams = new URLSearchParams(); - - searchParams.set('q', props.searchTerm); - searchParams.set('section', props.selectedFacetName); - - const allResultsURL = `/${locale}/search?${searchParams.toString()}`; - - return ( - <div className={styles.seeAllFulltextSearchResults}> - <NextLink href={allResultsURL} onClick={props.onSeeAllClick}> - {t('components.search.seeAll.text', { count: resultsCount })} - </NextLink> - </div> - ); -}; diff --git a/apps/site/components/Common/Search/States/WithError.tsx b/apps/site/components/Common/Search/States/WithError.tsx deleted file mode 100644 index 33eecbabd147d..0000000000000 --- a/apps/site/components/Common/Search/States/WithError.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import styles from './index.module.css'; - -export const WithError: FC = () => { - const t = useTranslations(); - - return ( - <div className={styles.searchErrorContainer}> - {t('components.search.searchError.text')} - </div> - ); -}; diff --git a/apps/site/components/Common/Search/States/WithNoResults.tsx b/apps/site/components/Common/Search/States/WithNoResults.tsx deleted file mode 100644 index 5b55c60469c4b..0000000000000 --- a/apps/site/components/Common/Search/States/WithNoResults.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import styles from './index.module.css'; - -type NoResultsProps = { searchTerm: string }; - -export const WithNoResults: FC<NoResultsProps> = props => { - const t = useTranslations(); - - return ( - <div className={styles.noResultsContainer}> - {t('components.search.noResults.text', { query: props.searchTerm })} - </div> - ); -}; diff --git a/apps/site/components/Common/Search/States/WithPoweredBy.tsx b/apps/site/components/Common/Search/States/WithPoweredBy.tsx deleted file mode 100644 index 3986280d5d7a6..0000000000000 --- a/apps/site/components/Common/Search/States/WithPoweredBy.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import { useTranslations } from 'next-intl'; -import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; - -import styles from './index.module.css'; - -const getLogoURL = (theme: string = 'dark') => - `https://website-assets.oramasearch.com/orama-when-${theme}.svg`; - -export const WithPoweredBy = () => { - const t = useTranslations(); - const { resolvedTheme } = useTheme(); - const [logoURL, setLogoURL] = useState<string>(); - - useEffect(() => setLogoURL(getLogoURL(resolvedTheme)), [resolvedTheme]); - - return ( - <div className={styles.poweredBy}> - {t('components.search.poweredBy.text')} - - <a - href="https://oramasearch.com?utm_source=nodejs.org" - target="_blank" - rel="noreferer" - > - {logoURL && ( - <Image - src={logoURL} - alt="Powered by OramaSearch" - className={styles.poweredByLogo} - width={80} - height={20} - /> - )} - </a> - </div> - ); -}; diff --git a/apps/site/components/Common/Search/States/WithSearchBox.tsx b/apps/site/components/Common/Search/States/WithSearchBox.tsx deleted file mode 100644 index d3a2cff45087c..0000000000000 --- a/apps/site/components/Common/Search/States/WithSearchBox.tsx +++ /dev/null @@ -1,256 +0,0 @@ -'use client'; - -import { - MagnifyingGlassIcon, - ChevronLeftIcon, -} from '@heroicons/react/24/outline'; -import type { Results, Nullable } from '@orama/orama'; -import { useState, useRef, useEffect } from 'react'; -import type { FC } from 'react'; - -import styles from '@/components/Common/Search/States/index.module.css'; -import { WithAllResults } from '@/components/Common/Search/States/WithAllResults'; -import { WithError } from '@/components/Common/Search/States/WithError'; -import { WithNoResults } from '@/components/Common/Search/States/WithNoResults'; -import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy'; -import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult'; -import Tabs from '@/components/Common/Tabs'; -import { useClickOutside, useKeyboardCommands } from '@/hooks/react-client'; -import { useRouter } from '@/navigation.mjs'; -import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs'; -import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs'; -import type { SearchDoc } from '@/types'; -import { searchHitToLinkPath } from '@/util/searchUtils'; - -type Facets = { [key: string]: number }; - -type SearchResults = Nullable<Results<SearchDoc>>; - -type SearchBoxProps = { onClose: () => void }; - -export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => { - const [searchTerm, setSearchTerm] = useState(''); - const [searchResults, setSearchResults] = useState<SearchResults>(null); - const [selectedResult, setSelectedResult] = useState<number>(); - const [selectedFacet, setSelectedFacet] = useState<number>(0); - const [searchError, setSearchError] = useState<Nullable<Error>>(null); - - const router = useRouter(); - const searchInputRef = useRef<HTMLInputElement>(null); - const searchBoxRef = useRef<HTMLDivElement>(null); - - const search = (term: string) => { - oramaSearch({ - term, - ...DEFAULT_ORAMA_QUERY_PARAMS, - mode: 'fulltext', - returning: [ - 'path', - 'pageSectionTitle', - 'pageTitle', - 'path', - 'siteSection', - ], - ...filterBySection(), - }) - .then(setSearchResults) - .catch(setSearchError); - }; - - const reset = () => { - setSearchTerm(''); - setSearchResults(null); - setSelectedResult(undefined); - setSelectedFacet(0); - }; - - const handleClose = () => { - reset(); - onClose(); - }; - - useClickOutside(searchBoxRef, handleClose); - - useEffect(() => { - searchInputRef.current?.focus(); - - getInitialFacets().then(setSearchResults).catch(setSearchError); - - return reset; - }, []); - - useEffect( - () => { - search(searchTerm); - }, - // we don't need to care about memoization of search function - // eslint-disable-next-line react-hooks/exhaustive-deps - [searchTerm, selectedFacet] - ); - - useKeyboardCommands(cmd => { - if (searchError || !searchResults || searchResults.count <= 0) { - return; - } - - switch (true) { - case cmd === 'down' && selectedResult === undefined: - setSelectedResult(0); - break; - case cmd === 'down' && - selectedResult != undefined && - selectedResult < searchResults.count && - selectedResult < DEFAULT_ORAMA_QUERY_PARAMS.limit - 1: - setSelectedResult(selectedResult + 1); - break; - case cmd === 'up' && selectedResult != undefined && selectedResult != 0: - setSelectedResult(selectedResult - 1); - break; - case cmd === 'enter': - handleEnter(); - break; - default: - } - }); - - const handleEnter = () => { - if (!searchResults || !selectedResult) { - return; - } - - const selectedHit = searchResults.hits[selectedResult]; - - if (!selectedHit) { - return; - } - - handleClose(); - router.push(searchHitToLinkPath(selectedHit)); - }; - - const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - - handleClose(); - router.push(`/search?q=${searchTerm}§ion=${selectedFacetName}`); - }; - - const changeFacet = (idx: string) => setSelectedFacet(Number(idx)); - - const filterBySection = () => { - if (selectedFacet === 0) { - return {}; - } - - return { where: { siteSection: { eq: selectedFacetName } } }; - }; - - const facets: Facets = { - all: searchResults?.facets - ? Object.values(searchResults?.facets.siteSection.values).reduce( - (a, b) => a + b, - 0 - ) - : 0, - ...(searchResults?.facets?.siteSection?.values ?? {}), - }; - - const selectedFacetName = Object.keys(facets)[selectedFacet]; - - return ( - <div className={styles.searchBoxModalContainer}> - <div className={styles.searchBoxModalPanel} ref={searchBoxRef}> - <div className={styles.searchBoxInnerPanel}> - <div className={styles.searchBoxInputContainer}> - <button - onClick={onClose} - className={styles.searchBoxBackIconContainer} - > - <ChevronLeftIcon className={styles.searchBoxBackIcon} /> - </button> - - <MagnifyingGlassIcon - className={styles.searchBoxMagnifyingGlassIcon} - /> - - <form onSubmit={onSubmit}> - <input - ref={searchInputRef} - aria-activedescendant={ - selectedResult !== undefined - ? `search-hit-${selectedResult}` - : undefined - } - aria-autocomplete="list" - aria-controls="fulltext-results-container" - aria-expanded={Boolean(!searchError && searchResults?.count)} - autoComplete="off" - role="combobox" - type="search" - className={styles.searchBoxInput} - onChange={event => setSearchTerm(event.target.value)} - value={searchTerm} - /> - </form> - </div> - - <div className={styles.fulltextSearchSections}> - <Tabs - activationMode="manual" - defaultValue="0" - autoFocus={true} - tabs={Object.keys(facets).map((facetName, idx) => ({ - key: facetName, - label: facetName, - secondaryLabel: `(${facets[facetName].toLocaleString('en')})`, - value: idx.toString(), - }))} - onValueChange={changeFacet} - /> - </div> - - <div - id="fulltext-results-container" - className={styles.fulltextResultsContainer} - role="listbox" - > - {searchError && <WithError />} - - {!searchError && ( - <> - {searchResults && - searchResults.count > 0 && - searchResults.hits.map((hit, idx) => ( - <WithSearchResult - key={hit.id} - hit={hit} - searchTerm={searchTerm} - selected={selectedResult === idx} - idx={idx} - /> - ))} - - {searchResults && searchResults.count === 0 && ( - <WithNoResults searchTerm={searchTerm} /> - )} - - {searchResults && searchResults.count > 8 && ( - <WithAllResults - searchResults={searchResults} - searchTerm={searchTerm} - selectedFacetName={selectedFacetName} - onSeeAllClick={onClose} - /> - )} - </> - )} - </div> - - <div className={styles.fulltextSearchFooter}> - <WithPoweredBy /> - </div> - </div> - </div> - </div> - ); -}; diff --git a/apps/site/components/Common/Search/States/WithSearchResult.tsx b/apps/site/components/Common/Search/States/WithSearchResult.tsx deleted file mode 100644 index 05e0dd47ef051..0000000000000 --- a/apps/site/components/Common/Search/States/WithSearchResult.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Result } from '@orama/orama'; -import { useEffect, type FC, useRef } from 'react'; - -import { pathToBreadcrumbs } from '@/components/Common/Search/utils'; -import Link from '@/components/Link'; -import { highlighter } from '@/next.orama.mjs'; -import type { SearchDoc } from '@/types'; -import { searchHitToLinkPath } from '@/util/searchUtils'; - -import styles from './index.module.css'; - -type SearchResultProps = { - hit: Result<SearchDoc>; - searchTerm: string; - selected: boolean; - idx: number; -}; - -export const WithSearchResult: FC<SearchResultProps> = props => { - const divRef = useRef<HTMLDivElement>(null); - const path = searchHitToLinkPath(props.hit); - - useEffect(() => { - if (props.selected && divRef.current) { - divRef.current.scrollIntoView({ block: 'center' }); - } - }, [props.selected]); - - return ( - <Link - id={`search-hit-${props.idx}`} - key={props.hit.id} - href={path} - className={styles.fulltextSearchResult} - role="option" - aria-selected={props.selected} - > - <div - className={styles.fulltextSearchResultTitle} - ref={divRef} - dangerouslySetInnerHTML={{ - __html: highlighter - .highlight(props.hit.document.pageSectionTitle, props.searchTerm) - .trim(125), - }} - /> - <div className={styles.fulltextSearchResultBreadcrumb}> - {pathToBreadcrumbs(props.hit.document.path).join(' > ')} - {' > '} - {props.hit.document.pageTitle} - </div> - </Link> - ); -}; diff --git a/apps/site/components/Common/Search/States/index.module.css b/apps/site/components/Common/Search/States/index.module.css deleted file mode 100644 index a4a3f7041c97e..0000000000000 --- a/apps/site/components/Common/Search/States/index.module.css +++ /dev/null @@ -1,192 +0,0 @@ -.searchBoxModalContainer { - @apply fixed - inset-0 - z-50 - flex - items-center - justify-center - bg-neutral-900 - bg-opacity-90 - dark:bg-neutral-900 - dark:bg-opacity-90; -} - -.searchBoxModalPanel { - @apply fixed - h-screen - w-full - bg-neutral-100 - md:h-[450px] - md:max-w-3xl - md:rounded-xl - md:shadow-lg - dark:bg-neutral-950; -} - -.searchBoxInnerPanel { - @apply pt-12 - text-neutral-800 - md:pt-2 - dark:text-neutral-400; -} - -.searchBoxMagnifyingGlassIcon { - @apply absolute - top-[10px] - hidden - size-6 - md:block; -} - -.searchBoxBackIconContainer { - @apply block - md:hidden; -} - -.searchBoxBackIcon { - @apply absolute - top-[7px] - block - size-6 - md:hidden; -} - -.searchBoxInputContainer { - @apply relative - px-2 - md:px-4; -} - -.searchBoxInput { - @apply w-full - rounded-b-none - border-b - border-neutral-300 - bg-transparent - py-2 - pl-8 - pr-4 - focus:outline-none - dark:border-neutral-900 - dark:text-neutral-300 - dark:placeholder-neutral-300; -} - -.fulltextResultsContainer { - @apply h-80 - overflow-auto - md:px-4; -} - -.fulltextSearchResult { - @apply flex - flex-col - rounded-md - p-2 - text-left - text-sm; - - &[aria-selected='true'], - &:hover { - @apply bg-neutral-300 - dark:bg-neutral-900; - } -} - -.fulltextSearchResultTitle { - @apply text-neutral-800 - dark:text-neutral-300; -} - -.fulltextSearchResultBreadcrumb { - @apply mt-1 - text-xs - capitalize - text-neutral-800 - dark:text-neutral-600; -} - -.fulltextSearchSections { - @apply mb-1 - mt-2 - p-2 - md:px-4; -} - -.seeAllFulltextSearchResults { - @apply m-auto - mb-2 - mt-4 - w-full - text-center - text-sm - text-neutral-700 - hover:underline - dark:text-neutral-600; -} - -.poweredBy { - @apply flex - text-xs - text-neutral-950 - dark:text-neutral-200; -} - -.poweredByLogo { - @apply ml-2 - w-16; -} - -.emptyStateContainer { - @apply flex - h-[80%] - w-full - flex-col - items-center - justify-center - text-center - text-sm - text-neutral-600 - dark:text-neutral-500; -} - -.noResultsContainer { - @apply flex - h-[80%] - w-full - items-center - justify-center - text-center - text-sm - text-neutral-600 - dark:text-neutral-500; -} - -.noResultsTerm { - @apply font-semibold; -} - -.searchErrorContainer { - @apply flex - h-[80%] - w-full - items-center - justify-center - text-center - text-sm - text-neutral-600 - dark:text-neutral-500; -} - -.fulltextSearchFooter { - @apply flex - w-full - justify-end - rounded-b-xl - border-t - border-neutral-300 - bg-neutral-100 - p-4 - dark:border-neutral-900 - dark:bg-neutral-950; -} diff --git a/apps/site/components/Common/Search/index.module.css b/apps/site/components/Common/Search/index.module.css deleted file mode 100644 index 734019bf8cad0..0000000000000 --- a/apps/site/components/Common/Search/index.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.searchButton { - @apply flex - grow - basis-80 - items-center - gap-2 - rounded-md - bg-neutral-200 - p-2 - text-sm - text-neutral-800 - hover:bg-neutral-300 - hover:text-neutral-900 - sm:mr-auto - dark:bg-neutral-900 - dark:text-neutral-600 - dark:hover:bg-neutral-800 - dark:hover:text-neutral-500; -} - -.magnifyingGlassIcon { - @apply size-5; -} - -.shortcutIndicator { - @apply font-ibm-plex-mono - invisible - flex - flex-1 - items-center - justify-end - self-center - px-1 - text-xs - motion-safe:transition-opacity - motion-safe:duration-100 - md:visible; -} diff --git a/apps/site/components/Common/Search/index.tsx b/apps/site/components/Common/Search/index.tsx deleted file mode 100644 index 652daa66f1d74..0000000000000 --- a/apps/site/components/Common/Search/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import classNames from 'classnames'; -import { useTranslations } from 'next-intl'; -import { useState, type FC } from 'react'; - -import { WithSearchBox } from '@/components/Common/Search/States/WithSearchBox'; -import { useDetectOS } from '@/hooks'; -import { useKeyboardCommands } from '@/hooks/react-client'; - -import styles from './index.module.css'; - -export const SearchButton: FC = () => { - const [isOpen, setIsOpen] = useState(false); - const t = useTranslations(); - const openSearchBox = () => setIsOpen(true); - const closeSearchBox = () => setIsOpen(false); - - useKeyboardCommands(cmd => { - switch (cmd) { - case 'cmd-k': - openSearchBox(); - break; - case 'escape': - closeSearchBox(); - break; - default: - } - }); - - const { os } = useDetectOS(); - - const osCommandKey = os === 'MAC' ? '⌘' : 'Ctrl'; - const isOSLoading = os === 'LOADING'; - - return ( - <> - <button - type="button" - onClick={openSearchBox} - className={styles.searchButton} - aria-label={t('components.search.searchBox.placeholder')} - > - <MagnifyingGlassIcon className={styles.magnifyingGlassIcon} /> - - {t('components.search.searchBox.placeholder')} - <kbd - title={`${osCommandKey} K`} - className={classNames(styles.shortcutIndicator, { - 'opacity-0': isOSLoading, - })} - > - <abbr>{osCommandKey} K</abbr> - </kbd> - </button> - - {isOpen ? <WithSearchBox onClose={closeSearchBox} /> : null} - </> - ); -}; diff --git a/apps/site/components/Common/Search/utils.ts b/apps/site/components/Common/Search/utils.ts deleted file mode 100644 index ca204dda9b64f..0000000000000 --- a/apps/site/components/Common/Search/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const pathToBreadcrumbs = (path: string) => - path - .replace(/#.+$/, '') - .split('/') - .slice(0, -1) - .map(element => element.replaceAll('-', ' ')) - .filter(Boolean); diff --git a/apps/site/components/Common/Select/__tests__/index.test.mjs b/apps/site/components/Common/Select/__tests__/index.test.mjs deleted file mode 100644 index 463bd67334338..0000000000000 --- a/apps/site/components/Common/Select/__tests__/index.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { render } from '@testing-library/react'; - -import Select from '..'; - -describe('Select', () => { - const values = ['Option 1', 'Option 2', 'Option 3']; - const defaultValue = 'Option 1'; - const placeholder = 'Select an option'; - const dropdownLabel = 'Dropdown label'; - const label = 'Option label'; - const onChange = jest.fn(); - - global.ResizeObserver = jest.fn().mockImplementation(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), - })); - - it('renders the label when provided', () => { - const { getByLabelText } = render( - <Select - values={values} - defaultValue={defaultValue} - placeholder={placeholder} - dropdownLabel={dropdownLabel} - label={label} - onChange={onChange} - /> - ); - - const element = getByLabelText(label); - expect(element).toBeInTheDocument(); - }); - - it('renders the default value when provided', () => { - const { getByText } = render( - <Select - values={values} - defaultValue={defaultValue} - placeholder={placeholder} - dropdownLabel={dropdownLabel} - label={label} - onChange={onChange} - /> - ); - - const element = getByText(defaultValue); - expect(element).toBeInTheDocument(); - }); - - it('renders the placeholder when default value is not provided', () => { - const { getByText } = render( - <Select - values={values} - placeholder={placeholder} - dropdownLabel={dropdownLabel} - label={label} - onChange={onChange} - /> - ); - - const element = getByText(placeholder); - expect(element).toBeInTheDocument(); - }); -}); diff --git a/apps/site/components/Common/Select/index.module.css b/apps/site/components/Common/Select/index.module.css deleted file mode 100644 index aaedbd4355e22..0000000000000 --- a/apps/site/components/Common/Select/index.module.css +++ /dev/null @@ -1,141 +0,0 @@ -.select { - @apply inline-flex - flex-col - gap-1.5; - - .label { - @apply block - w-full - text-sm - font-medium - text-neutral-800 - dark:text-neutral-200; - } - - .trigger { - @apply shadow-xs - inline-flex - h-11 - w-full - min-w-[17rem] - items-center - justify-between - gap-2 - rounded - border - border-neutral-300 - bg-white - px-3.5 - py-2.5 - text-left - text-base - font-medium - text-neutral-900 - outline-none - focus:border-neutral-500 - focus:ring-1 - focus:ring-neutral-500 - data-[placeholder]:text-neutral-800 - dark:border-neutral-800 - dark:bg-neutral-950 - dark:text-white - dark:focus:border-neutral-600 - dark:focus:ring-neutral-600 - dark:data-[placeholder]:text-neutral-200; - } - - .trigger span { - @apply flex - h-5 - items-center - gap-2; - } - - .icon { - @apply size-5 - text-neutral-600 - dark:text-neutral-400; - } -} - -.dropdown { - @apply max-h-48 - max-w-xs - overflow-hidden - overflow-y-auto - rounded-md - border - border-neutral-200 - bg-white - shadow-lg - dark:border-neutral-800 - dark:bg-neutral-950; - - .item { - @apply select-none - truncate - px-2.5 - py-1.5 - text-sm - font-medium; - } - - .text { - @apply text-neutral-800 - data-[highlighted]:bg-green-500 - data-[highlighted]:text-white - data-[highlighted]:outline-none - dark:text-neutral-200 - dark:data-[highlighted]:bg-green-600; - } - - .text > span { - @apply flex - items-center - gap-2; - } - - .text > span > span { - @apply truncate; - } - - .label { - @apply text-neutral-600 - dark:text-neutral-400; - } -} - -.dropdown:has(.label) .text > span > span { - @apply pl-3; -} - -.inline { - .trigger { - @apply h-auto - min-w-fit - px-2.5 - py-2 - text-sm - font-medium; - } - - .icon { - @apply size-4; - } - - .text { - @apply text-neutral-900 - data-[highlighted]:bg-neutral-100 - data-[disabled]:text-neutral-600 - data-[highlighted]:text-neutral-900 - dark:text-white - dark:data-[highlighted]:bg-neutral-900 - dark:data-[disabled]:text-neutral-700; - } - - &.dropdown { - @apply mt-1 - w-[calc(100%+1.5rem)] - rounded; - } -} diff --git a/apps/site/components/Common/Select/index.stories.tsx b/apps/site/components/Common/Select/index.stories.tsx deleted file mode 100644 index c233b69cf1f17..0000000000000 --- a/apps/site/components/Common/Select/index.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Select from '@/components/Common/Select'; -import Apple from '@/components/Icons/Platform/Apple'; -import Linux from '@/components/Icons/Platform/Linux'; -import Microsoft from '@/components/Icons/Platform/Microsoft'; - -type Story = StoryObj<typeof Select>; -type Meta = MetaObj<typeof Select>; - -export const Default: Story = { - args: { - values: ['v20.8.0', 'v19.9.0', 'v18.18.0', 'v17.9.1', 'v16.20.2'], - defaultValue: 'v16.20.2', - label: 'Node.js version', - }, -}; - -export const WithoutLabel: Story = { - args: { - values: ['v20.8.0', 'v19.9.0', 'v18.18.0', 'v17.9.1', 'v16.20.2'], - defaultValue: 'v16.20.2', - }, -}; - -export const DropdownLabel: Story = { - args: { - values: [ - { - label: 'Getting Started', - items: [ - { - value: 'section-1', - label: 'Getting Started', - }, - { - value: 'section-2', - label: 'How to install Node.js', - }, - { - value: 'section-3', - label: 'How much JavaScript do you need to know to use Node.js?', - }, - { - value: 'section-4', - label: 'Differences between Node.js and the Browser', - }, - { - value: 'section-5', - label: 'The V8 JavaScript Engine', - }, - { - value: 'section-6', - label: 'An introduction to the npm package manager', - }, - { - value: 'section-7', - label: 'ECMAScript 2015 (ES6) and beyond', - }, - { - value: 'section-8', - label: 'Node.js, the difference between development and production', - }, - ], - }, - ], - placeholder: 'Select a guide', - label: 'Getting Started', - }, -}; - -export const InlineSelect: Story = { - args: { - values: [ - { - label: 'Platform', - items: [ - { - value: 'linux', - label: 'Linux', - iconImage: <Linux width={16} height={16} />, - }, - { - value: 'macos', - label: 'macOS', - iconImage: <Apple width={16} height={16} />, - }, - { - value: 'windows', - label: 'Windows', - iconImage: <Microsoft width={16} height={16} />, - }, - ], - }, - ], - defaultValue: 'macos', - inline: true, - }, -}; - -export default { component: Select } as Meta; diff --git a/apps/site/components/Common/Select/index.tsx b/apps/site/components/Common/Select/index.tsx deleted file mode 100644 index 3d12632e7ed20..0000000000000 --- a/apps/site/components/Common/Select/index.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'use client'; - -import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import * as ScrollPrimitive from '@radix-ui/react-scroll-area'; -import * as SelectPrimitive from '@radix-ui/react-select'; -import classNames from 'classnames'; -import { useId, useMemo } from 'react'; -import type { FC } from 'react'; - -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type SelectValue = { - label: FormattedMessage; - value: string; - iconImage?: React.ReactNode; - disabled?: boolean; -}; - -type SelectGroup = { - label?: FormattedMessage; - items: Array<SelectValue>; -}; - -const isStringArray = (values: Array<unknown>): values is Array<string> => - Boolean(values[0] && typeof values[0] === 'string'); - -const isValuesArray = (values: Array<unknown>): values is Array<SelectValue> => - Boolean(values[0] && typeof values[0] === 'object' && 'value' in values[0]); - -type SelectProps = { - values: Array<SelectGroup> | Array<SelectValue> | Array<string>; - defaultValue?: string; - placeholder?: string; - label?: string; - inline?: boolean; - onChange?: (value: string) => void; - className?: string; - ariaLabel?: string; -}; - -const Select: FC<SelectProps> = ({ - values = [], - defaultValue, - placeholder, - label, - inline, - onChange, - className, - ariaLabel, -}) => { - const id = useId(); - - const mappedValues = useMemo(() => { - let mappedValues = values; - - if (isStringArray(mappedValues)) { - mappedValues = mappedValues.map(value => ({ label: value, value })); - } - - if (isValuesArray(mappedValues)) { - return [{ items: mappedValues }]; - } - - return mappedValues; - }, [values]); - - return ( - <span - className={classNames( - styles.select, - { [styles.inline]: inline }, - className - )} - > - {label && ( - <label className={styles.label} htmlFor={id}> - {label} - </label> - )} - - <SelectPrimitive.Root value={defaultValue} onValueChange={onChange}> - <SelectPrimitive.Trigger - className={styles.trigger} - aria-label={ariaLabel} - id={id} - > - <SelectPrimitive.Value placeholder={placeholder} /> - <ChevronDownIcon className={styles.icon} /> - </SelectPrimitive.Trigger> - - <SelectPrimitive.Portal> - <SelectPrimitive.Content - position={inline ? 'popper' : 'item-aligned'} - className={classNames(styles.dropdown, { [styles.inline]: inline })} - > - <ScrollPrimitive.Root type="auto"> - <SelectPrimitive.Viewport> - <ScrollPrimitive.Viewport> - {mappedValues.map(({ label, items }, key) => ( - <SelectPrimitive.Group key={label?.toString() ?? key}> - {label && ( - <SelectPrimitive.Label - className={classNames(styles.item, styles.label)} - > - {label} - </SelectPrimitive.Label> - )} - - {items.map(({ value, label, iconImage, disabled }) => ( - <SelectPrimitive.Item - key={value} - value={value} - disabled={disabled} - className={classNames(styles.item, styles.text)} - > - <SelectPrimitive.ItemText> - {iconImage} - <span>{label}</span> - </SelectPrimitive.ItemText> - </SelectPrimitive.Item> - ))} - </SelectPrimitive.Group> - ))} - </ScrollPrimitive.Viewport> - </SelectPrimitive.Viewport> - <ScrollPrimitive.Scrollbar orientation="vertical"> - <ScrollPrimitive.Thumb /> - </ScrollPrimitive.Scrollbar> - </ScrollPrimitive.Root> - </SelectPrimitive.Content> - </SelectPrimitive.Portal> - </SelectPrimitive.Root> - </span> - ); -}; - -export default Select; diff --git a/apps/site/components/Common/Supporters/index.tsx b/apps/site/components/Common/Supporters/index.tsx new file mode 100644 index 0000000000000..2797e94241ed9 --- /dev/null +++ b/apps/site/components/Common/Supporters/index.tsx @@ -0,0 +1,26 @@ +import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar'; + +import { getAcronymFromString } from '#site/util/string'; + +import type { Supporter } from '#site/types'; +import type { FC } from 'react'; + +type SupportersListProps = { + supporters: Array<Supporter<'opencollective' | 'github'>>; +}; + +const SupportersList: FC<SupportersListProps> = ({ supporters }) => ( + <div className="flex max-w-full flex-wrap items-center justify-center gap-1"> + {supporters.map(({ name, image, source, url }) => ( + <Avatar + nickname={name} + fallback={getAcronymFromString(name)} + image={image} + key={`${source}:${name}`} + url={url} + /> + ))} + </div> +); + +export default SupportersList; diff --git a/apps/site/components/Common/Tabs/__tests__/index.test.mjs b/apps/site/components/Common/Tabs/__tests__/index.test.mjs deleted file mode 100644 index a5fccf803e11c..0000000000000 --- a/apps/site/components/Common/Tabs/__tests__/index.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import Tabs from '../index'; - -describe('Tabs', () => { - const Sut = ({ addons }) => { - const tabs = [ - { key: 'package', label: 'Package Manager' }, - { key: 'prebuilt', label: 'Prebuilt Installer' }, - { key: 'source', label: 'Source Code' }, - ]; - - return ( - <Tabs tabs={tabs} defaultValue="package" addons={addons}> - <TabsPrimitive.Content value="package"> - Package Manager - </TabsPrimitive.Content> - <TabsPrimitive.Content value="prebuilt"> - Prebuilt Installer - </TabsPrimitive.Content> - <TabsPrimitive.Content value="source"> - Source Code - </TabsPrimitive.Content> - </Tabs> - ); - }; - - it('should render the correct number of tabs', () => { - render(<Sut />); - - expect(screen.getAllByRole('tab')).toHaveLength(3); - }); - - it('should render the correct tab content when clicked', async () => { - render(<Sut />); - - expect(screen.getByRole('tabpanel')).toHaveTextContent('Package Manager'); - - await act(async () => { - await userEvent.click(screen.getByRole('tab', { name: 'Source Code' })); - }); - - expect(screen.getByRole('tabpanel')).toHaveTextContent('Source Code'); - }); - - it('should render the given addons', async () => { - render(<Sut addons={<a href="/">addon</a>} />); - - expect(screen.getByRole('link', { name: 'addon' })).toBeInTheDocument(); - }); -}); diff --git a/apps/site/components/Common/Tabs/index.module.css b/apps/site/components/Common/Tabs/index.module.css deleted file mode 100644 index ca7f665064769..0000000000000 --- a/apps/site/components/Common/Tabs/index.module.css +++ /dev/null @@ -1,50 +0,0 @@ -.tabsRoot { - @apply max-w-full; - - .tabsList { - @apply font-open-sans - flex - gap-2 - overflow-x-auto; - - .tabsTrigger { - @apply whitespace-nowrap - border-b-2 - border-b-transparent - px-1 - pb-[11px] - text-sm - font-semibold - text-neutral-800 - dark:text-neutral-200; - - .tabSecondaryLabel { - @apply pl-1 - text-neutral-500 - dark:text-neutral-800; - } - - &[data-state='active'] { - @apply border-b-green-600 - text-green-600 - dark:border-b-green-400 - dark:text-green-400; - - .tabSecondaryLabel { - @apply text-green-800 - dark:text-green-600; - } - } - } - - .addons { - @apply ml-auto - border-b-2 - border-b-transparent - px-1 - pb-[11px] - text-sm - font-semibold; - } - } -} diff --git a/apps/site/components/Common/Tabs/index.stories.tsx b/apps/site/components/Common/Tabs/index.stories.tsx deleted file mode 100644 index 5cd3767843033..0000000000000 --- a/apps/site/components/Common/Tabs/index.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import type { ComponentProps } from 'react'; - -import Tabs from '@/components/Common/Tabs'; - -type Story = StoryObj<typeof Tabs>; -type Meta = MetaObj<typeof Tabs>; - -const defaultArgs: ComponentProps<typeof Tabs> = { - defaultValue: 'prebuilt', - tabs: [ - { - key: 'package', - label: 'Package Manager', - }, - { - key: 'prebuilt', - label: 'Prebuilt Installer', - }, - { - key: 'source', - label: 'Source Code', - }, - ], - children: ( - <> - <TabsPrimitive.Content value="package"> - Package Manager - </TabsPrimitive.Content> - <TabsPrimitive.Content value="prebuilt"> - Prebuilt Installer - </TabsPrimitive.Content> - <TabsPrimitive.Content value="source">Source Code</TabsPrimitive.Content> - </> - ), -}; - -export const Default: Story = { - args: defaultArgs, -}; - -export const WithAddon: Story = { - args: { - ...defaultArgs, - addons: 'addon', - }, -}; - -export default { component: Tabs } as Meta; diff --git a/apps/site/components/Common/Tabs/index.tsx b/apps/site/components/Common/Tabs/index.tsx deleted file mode 100644 index b02233a731af8..0000000000000 --- a/apps/site/components/Common/Tabs/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import classNames from 'classnames'; -import type { FC, PropsWithChildren, ReactNode } from 'react'; - -import styles from './index.module.css'; - -type Tab = { - key: string; - label: string; - secondaryLabel?: string; - value?: string; -}; - -type TabsProps = TabsPrimitive.TabsProps & { - tabs: Array<Tab>; - addons?: ReactNode; - triggerClassName?: string; -}; - -const Tabs: FC<PropsWithChildren<TabsProps>> = ({ - tabs, - addons, - triggerClassName, - children, - ...props -}) => ( - <TabsPrimitive.Root - {...props} - className={classNames(styles.tabsRoot, props.className)} - > - <TabsPrimitive.List className={styles.tabsList}> - {tabs.map(tab => ( - <TabsPrimitive.Trigger - key={tab.key} - value={tab.value ?? tab.key} - className={classNames(styles.tabsTrigger, triggerClassName)} - > - {tab.label} - {tab.secondaryLabel ? ( - <span className={styles.tabSecondaryLabel}> - {tab.secondaryLabel} - </span> - ) : null} - </TabsPrimitive.Trigger> - ))} - - {addons && <div className={styles.addons}>{addons}</div>} - </TabsPrimitive.List> - - {children} - </TabsPrimitive.Root> -); - -export default Tabs; diff --git a/apps/site/components/Common/ThemeToggle/__tests__/index.test.mjs b/apps/site/components/Common/ThemeToggle/__tests__/index.test.mjs deleted file mode 100644 index a719c4705add4..0000000000000 --- a/apps/site/components/Common/ThemeToggle/__tests__/index.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import ThemeToggle from '../'; - -let mockCurrentTheme = 'light'; - -const toggleTheme = () => { - mockCurrentTheme = mockCurrentTheme === 'light' ? 'dark' : 'light'; -}; - -describe('ThemeToggle', () => { - let toggle; - - beforeEach(() => { - mockCurrentTheme = 'light'; - - render(<ThemeToggle onClick={toggleTheme} />); - toggle = screen.getByRole('button'); - }); - - it('switches dark theme to light theme', async () => { - mockCurrentTheme = 'dark'; - await userEvent.click(toggle); - expect(mockCurrentTheme).toBe('light'); - }); - - it('switches light theme to dark theme', async () => { - await userEvent.click(toggle); - expect(mockCurrentTheme).toBe('dark'); - }); -}); diff --git a/apps/site/components/Common/ThemeToggle/index.module.css b/apps/site/components/Common/ThemeToggle/index.module.css deleted file mode 100644 index 3daab7785cfb3..0000000000000 --- a/apps/site/components/Common/ThemeToggle/index.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.themeToggle { - @apply size-9 - rounded-md - p-2 - text-neutral-700 - dark:text-neutral-300; - - &:hover { - @apply bg-neutral-100 - dark:bg-neutral-900; - } -} diff --git a/apps/site/components/Common/ThemeToggle/index.stories.tsx b/apps/site/components/Common/ThemeToggle/index.stories.tsx deleted file mode 100644 index d249c438179ed..0000000000000 --- a/apps/site/components/Common/ThemeToggle/index.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import ThemeToggle from '@/components/Common/ThemeToggle'; - -type Story = StoryObj<typeof ThemeToggle>; -type Meta = MetaObj<typeof ThemeToggle>; - -export const Default: Story = {}; - -export default { component: ThemeToggle } as Meta; diff --git a/apps/site/components/Common/ThemeToggle/index.tsx b/apps/site/components/Common/ThemeToggle/index.tsx deleted file mode 100644 index ac2f06fefc8f4..0000000000000 --- a/apps/site/components/Common/ThemeToggle/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'; -import { useTranslations } from 'next-intl'; -import type { FC, MouseEvent } from 'react'; - -import styles from './index.module.css'; - -type ThemeToggleProps = { - onClick?: (event: MouseEvent<HTMLButtonElement>) => void; -}; - -const ThemeToggle: FC<ThemeToggleProps> = ({ onClick = () => {} }) => { - const t = useTranslations(); - - const ariaLabel = t('components.common.themeToggle.label'); - - return ( - <button - type="button" - onClick={onClick} - className={styles.themeToggle} - aria-label={ariaLabel} - > - <MoonIcon className="block dark:hidden" height="20" /> - <SunIcon className="hidden dark:block" height="20" /> - </button> - ); -}; - -export default ThemeToggle; diff --git a/apps/site/components/Common/Turtle/index.module.css b/apps/site/components/Common/Turtle/index.module.css new file mode 100644 index 0000000000000..82bacb8ee7d5e --- /dev/null +++ b/apps/site/components/Common/Turtle/index.module.css @@ -0,0 +1,31 @@ +@reference "../../../styles/index.css"; + +.turtle { + @apply motion-safe:animate-surf + animate-surf + absolute + z-10 + translate-x-0 + translate-y-0 + after:absolute + after:top-[20%] + after:-left-full + after:-z-10 + after:block + after:h-36 + after:w-36 + after:-rotate-90 + after:bg-[url('/static/images/smoke.gif')] + after:opacity-[0.15] + after:content-[''] + after:select-none + motion-reduce:animate-none + after:md:top-1/2 + after:md:-left-1/2; +} + +.image { + @apply h-auto + w-24 + md:w-48; +} diff --git a/apps/site/components/Common/Turtle/index.tsx b/apps/site/components/Common/Turtle/index.tsx new file mode 100644 index 0000000000000..9f73e6ac1afb2 --- /dev/null +++ b/apps/site/components/Common/Turtle/index.tsx @@ -0,0 +1,19 @@ +import Image from 'next/image'; + +import type { FC } from 'react'; + +import styles from './index.module.css'; + +const Turtle: FC = () => ( + <div className={styles.turtle}> + <Image + className={styles.image} + src="/static/images/node-mascot.svg" + alt="The Node.js mascot" + height={115} + width={100} + /> + </div> +); + +export default Turtle; diff --git a/apps/site/components/Containers/Footer/index.module.css b/apps/site/components/Containers/Footer/index.module.css deleted file mode 100644 index e388fa5341dba..0000000000000 --- a/apps/site/components/Containers/Footer/index.module.css +++ /dev/null @@ -1,44 +0,0 @@ -.footer { - @apply flex - flex-col - items-center - gap-6 - border-t - border-neutral-200 - bg-white - py-4 - sm:px-8 - md:flex-row - md:justify-between - md:py-5 - dark:border-neutral-900 - dark:bg-neutral-950; - - .sectionPrimary { - @apply flex - flex-wrap - content-start - items-center - justify-center - gap-1 - self-stretch; - - a { - @apply whitespace-nowrap; - } - } - - .sectionSecondary { - @apply flex - flex-col - items-center - gap-1 - md:flex-row; - - .social { - @apply flex - items-center - gap-1; - } - } -} diff --git a/apps/site/components/Containers/Footer/index.stories.tsx b/apps/site/components/Containers/Footer/index.stories.tsx deleted file mode 100644 index ec44bb46b083b..0000000000000 --- a/apps/site/components/Containers/Footer/index.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Footer from '@/components/Containers/Footer'; - -type Story = StoryObj<typeof Footer>; -type Meta = MetaObj<typeof Footer>; - -export const Default: Story = {}; - -export default { component: Footer } as Meta; diff --git a/apps/site/components/Containers/Footer/index.tsx b/apps/site/components/Containers/Footer/index.tsx deleted file mode 100644 index 97a68475a2944..0000000000000 --- a/apps/site/components/Containers/Footer/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC, SVGProps } from 'react'; - -import NavItem from '@/components/Containers/NavBar/NavItem'; -import GitHub from '@/components/Icons/Social/GitHub'; -import LinkedIn from '@/components/Icons/Social/LinkedIn'; -import Mastodon from '@/components/Icons/Social/Mastodon'; -import Slack from '@/components/Icons/Social/Slack'; -import Twitter from '@/components/Icons/Social/Twitter'; -import { siteNavigation } from '@/next.json.mjs'; - -import styles from './index.module.css'; - -const footerSocialIcons: Record<string, React.FC<SVGProps<SVGSVGElement>>> = { - github: GitHub, - mastodon: Mastodon, - twitter: Twitter, - slack: Slack, - linkedin: LinkedIn, -}; - -const Footer: FC = () => { - const t = useTranslations(); - - const openJSlink = siteNavigation.footerLinks.at(-1)!; - - return ( - <footer className={styles.footer}> - <div className={styles.sectionPrimary}> - {siteNavigation.footerLinks.slice(0, -1).map(item => ( - <NavItem type="footer" href={item.link} key={item.link}> - {t(item.text)} - </NavItem> - ))} - </div> - - <div className={styles.sectionSecondary}> - <NavItem type="footer" href={openJSlink.link}> - © {openJSlink.text} - </NavItem> - - <div className={styles.social}> - {siteNavigation.socialLinks.map(link => { - const SocialIcon = footerSocialIcons[link.icon]; - - return ( - <NavItem key={link.icon} href={link.link} type="footer"> - <SocialIcon width={20} height={20} aria-label={link.link} /> - </NavItem> - ); - })} - </div> - </div> - </footer> - ); -}; - -export default Footer; diff --git a/apps/site/components/Containers/MetaBar/index.module.css b/apps/site/components/Containers/MetaBar/index.module.css deleted file mode 100644 index e8b649d5f554c..0000000000000 --- a/apps/site/components/Containers/MetaBar/index.module.css +++ /dev/null @@ -1,78 +0,0 @@ -.wrapper { - @apply flex - w-full - flex-col - items-start - gap-8 - overflow-y-auto - border-neutral-200 - px-4 - py-6 - [overflow-wrap:anywhere] - lg:px-6 - dark:border-neutral-900; - - dl { - @apply w-full; - } - - dt { - @apply mb-2 - text-sm - font-medium - text-neutral-800 - dark:text-neutral-200; - } - - dd { - @apply mb-8 - flex - items-center - gap-2 - text-sm - text-neutral-900 - dark:text-white; - - a { - @apply max-xs:inline-block - max-xs:py-1 - font-semibold - text-neutral-900 - underline - dark:text-white; - - &:hover { - @apply text-neutral-800 - dark:text-neutral-200; - } - } - - ol { - @apply flex - list-none - flex-col - gap-1.5 - p-0; - } - - svg { - @apply size-4 - text-neutral-600 - dark:text-neutral-400; - } - - &:last-child { - @apply mb-0; - } - } - - [data-on-dark] { - @apply hidden - dark:block; - } - - [data-on-light] { - @apply block - dark:hidden; - } -} diff --git a/apps/site/components/Containers/MetaBar/index.stories.tsx b/apps/site/components/Containers/MetaBar/index.stories.tsx deleted file mode 100644 index bf2ca8ac0e7d8..0000000000000 --- a/apps/site/components/Containers/MetaBar/index.stories.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { CodeBracketIcon } from '@heroicons/react/24/outline'; -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import AvatarGroup from '@/components/Common/AvatarGroup'; -import MetaBar from '@/components/Containers/MetaBar'; -import GitHub from '@/components/Icons/Social/GitHub'; -import Link from '@/components/Link'; -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -type Story = StoryObj<typeof MetaBar>; -type Meta = MetaObj<typeof MetaBar>; - -const names = [ - 'ovflowd', - 'bmuenzenmeyer', - 'AugustinMauroy', - 'HinataKah0', - 'Harkunwar', - 'rodion-arr', - 'mikeesto', - 'bnb', - 'benhalverson', - 'aymen94', - 'shanpriyan', - 'Wai-Dung', - 'manishprivet', - 'araujogui', -]; - -export const Default: Story = { - args: { - items: { - 'components.metabar.lastUpdated': new Date( - '17 October 2023' - ).toLocaleDateString(), - 'components.metabar.readingTime': '15 minutes', - 'components.metabar.addedIn': 'v1.0.0', - 'components.metabar.author': 'The Node.js Project', - 'components.metabar.authors': ( - <AvatarGroup - avatars={names.map(name => ({ - src: getGitHubAvatarUrl(name), - alt: name, - }))} - limit={8} - /> - ), - 'components.metabar.contribute': ( - <> - <GitHub className="fill-neutral-700 dark:fill-neutral-100" /> - <Link href="/contribute">Edit this page</Link> - </> - ), - 'components.metabar.viewAs': ( - <> - <CodeBracketIcon /> - <Link href="/json">JSON</Link> - </> - ), - }, - headings: { - items: [ - { - value: 'OpenSSL update assessment, and Node.js project plans', - depth: 1, - data: { id: 'heading-1' }, - }, - { - value: 'Summary', - depth: 2, - data: { id: 'summary' }, - }, - { - value: 'Analysis', - depth: 2, - data: { id: 'analysis' }, - }, - { - value: 'The c_rehash script allows command injection (CVE-2022-2068)', - depth: 3, - data: { id: 'the_c_rehash' }, - }, - { - value: 'Contact and future updates', - depth: 3, - data: { id: 'contact_and_future_updates' }, - }, - ], - }, - }, -}; - -export default { component: MetaBar } as Meta; diff --git a/apps/site/components/Containers/MetaBar/index.tsx b/apps/site/components/Containers/MetaBar/index.tsx deleted file mode 100644 index b4bc0fccf70f8..0000000000000 --- a/apps/site/components/Containers/MetaBar/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Heading } from '@vcarl/remark-headings'; -import { useTranslations } from 'next-intl'; -import { Fragment, useMemo } from 'react'; -import type { FC } from 'react'; - -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type MetaBarProps = { - items: Record<string, React.ReactNode>; - headings?: { - items: Array<Heading>; - minDepth?: number; - }; -}; - -const MetaBar: FC<MetaBarProps> = ({ items, headings }) => { - const t = useTranslations(); - - // The default depth of headings to display in the table of contents. - const { minDepth = 2, items: headingItems = [] } = headings || {}; - - const heading = useMemo( - () => headingItems.filter(({ depth }) => depth >= minDepth && depth <= 4), - [minDepth, headingItems] - ); - - return ( - <div className={styles.wrapper}> - <dl> - {Object.entries(items) - .filter(([, value]) => !!value) - .map(([key, value]) => ( - <Fragment key={key}> - <dt>{t(key)}</dt> - <dd>{value}</dd> - </Fragment> - ))} - - {heading.length > 0 && ( - <> - <dt>{t('components.metabar.tableOfContents')}</dt> - <dd> - <ol> - {heading.map(head => ( - <li key={head.value}> - <Link href={`#${head?.data?.id}`}>{head.value}</Link> - </li> - ))} - </ol> - </dd> - </> - )} - </dl> - </div> - ); -}; - -export default MetaBar; diff --git a/apps/site/components/Containers/NavBar/NavItem/index.module.css b/apps/site/components/Containers/NavBar/NavItem/index.module.css deleted file mode 100644 index b639699d08463..0000000000000 --- a/apps/site/components/Containers/NavBar/NavItem/index.module.css +++ /dev/null @@ -1,57 +0,0 @@ -.navItem { - @apply inline-flex - items-center - gap-2 - rounded - px-3 - py-2; - - .label { - @apply text-base - font-medium - lg:text-sm; - } - - .icon { - @apply size-3 - text-neutral-500 - dark:text-neutral-200; - } - - &:hover { - @apply bg-neutral-100 - dark:bg-neutral-900; - } - - &.nav { - .label { - @apply text-neutral-900 - dark:text-white; - } - - &.active { - @apply bg-green-600; - - .label { - @apply text-white; - } - - .icon { - @apply text-white - opacity-50; - } - } - } - - &.footer { - .label { - @apply text-neutral-800 - dark:text-white; - } - - &:hover { - @apply bg-neutral-100 - dark:bg-neutral-900; - } - } -} diff --git a/apps/site/components/Containers/NavBar/NavItem/index.stories.tsx b/apps/site/components/Containers/NavBar/NavItem/index.stories.tsx deleted file mode 100644 index e861afef7691f..0000000000000 --- a/apps/site/components/Containers/NavBar/NavItem/index.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import NavItem from '@/components/Containers/NavBar/NavItem'; - -type Story = StoryObj<typeof NavItem>; -type Meta = MetaObj<typeof NavItem>; - -export const Default: Story = { - args: { - href: '/learn', - children: 'Learn', - }, -}; - -export const WithExternalLink: Story = { - args: { - href: 'https://nodejs.org/en', - children: 'Learn', - }, -}; - -export const WithChildren: Story = { - args: { - href: 'https://nodejs.org/en', - children: <b>Learn</b>, - }, -}; - -export const FooterItem: Story = { - args: { - href: '/about', - children: 'Trademark Policy', - type: 'footer', - }, -}; - -export default { component: NavItem } as Meta; diff --git a/apps/site/components/Containers/NavBar/NavItem/index.tsx b/apps/site/components/Containers/NavBar/NavItem/index.tsx deleted file mode 100644 index 9e9ff0ce7c248..0000000000000 --- a/apps/site/components/Containers/NavBar/NavItem/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; -import classNames from 'classnames'; -import type { FC, HTMLAttributeAnchorTarget, PropsWithChildren } from 'react'; - -import ActiveLink from '@/components/Common/ActiveLink'; - -import styles from './index.module.css'; - -type NavItemType = 'nav' | 'footer'; - -type NavItemProps = { - href: string; - type?: NavItemType; - className?: string; - target?: HTMLAttributeAnchorTarget | undefined; -}; - -const NavItem: FC<PropsWithChildren<NavItemProps>> = ({ - href = '', - type = 'nav', - children, - className, - target, -}) => ( - <ActiveLink - href={href} - className={classNames(styles.navItem, styles[type], className)} - activeClassName={styles.active} - allowSubPath={href.startsWith('/')} - target={target} - > - <span className={styles.label}>{children}</span> - - {target === '_blank' && <ArrowUpRightIcon className={styles.icon} />} - </ActiveLink> -); - -export default NavItem; diff --git a/apps/site/components/Containers/NavBar/index.module.css b/apps/site/components/Containers/NavBar/index.module.css deleted file mode 100644 index e31532c14c759..0000000000000 --- a/apps/site/components/Containers/NavBar/index.module.css +++ /dev/null @@ -1,103 +0,0 @@ -.container { - @apply border-neutral-200 - bg-white - lg:flex - lg:h-16 - lg:flex-row - lg:items-center - lg:gap-8 - lg:border-b - lg:px-8 - dark:border-neutral-900 - dark:bg-neutral-950; -} - -.nodeIconAndMobileItemsToggler { - @apply flex - h-16 - shrink-0 - items-center - border-b - border-neutral-200 - px-4 - lg:flex - lg:h-full - lg:items-center - lg:border-0 - lg:px-0 - dark:border-neutral-900; -} - -.nodeIconWrapper { - @apply h-[30px] - flex-1; -} - -.navInteractionIcon { - @apply size-6; -} - -.sidebarItemTogglerLabel { - @apply block - cursor-pointer - lg:hidden; -} - -.main { - @apply hidden - flex-1 - flex-col - justify-between - gap-4 - lg:flex - lg:flex-row - lg:items-center; -} - -.navItems { - @apply flex - flex-col - gap-0 - border-b - border-neutral-200 - p-4 - lg:flex-1 - lg:flex-row - lg:gap-1 - lg:border-0 - lg:p-0 - dark:border-neutral-900; -} - -.actionsWrapper { - @apply flex - flex-row - flex-wrap - items-center - justify-between - gap-2 - border-b - border-neutral-200 - p-4 - sm:flex-nowrap - lg:basis-96 - lg:border-0 - lg:p-0 - dark:border-neutral-900; -} - -.ghIconWrapper { - @apply size-9 - rounded-md - p-2; - - svg { - @apply fill-neutral-700 - dark:fill-neutral-300; - } - - &:hover { - @apply bg-neutral-100 - dark:bg-neutral-900; - } -} diff --git a/apps/site/components/Containers/NavBar/index.stories.tsx b/apps/site/components/Containers/NavBar/index.stories.tsx deleted file mode 100644 index 33a945323fb3b..0000000000000 --- a/apps/site/components/Containers/NavBar/index.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import NavBar from '@/components/Containers/NavBar'; - -type Story = StoryObj<typeof NavBar>; -type Meta = MetaObj<typeof NavBar>; - -export const Default: Story = { - args: { - navItems: [ - { - text: 'Learn', - link: '/', - }, - { - text: 'About', - link: '/about', - }, - { - text: 'Docs', - link: '/docs', - }, - { - text: 'Download', - link: '/download', - }, - { - text: 'Blog', - link: '/blog', - }, - { - text: 'Certification', - link: 'https://openjsf.org/certification', - }, - ], - languages: { - availableLanguages: [ - { name: 'English', code: 'en' }, - { name: 'French', code: 'fr' }, - { name: 'Spanish', code: 'es' }, - ], - currentLanguage: 'en', - }, - onThemeTogglerClick: () => {}, - }, -}; - -export default { component: NavBar } as Meta; diff --git a/apps/site/components/Containers/NavBar/index.tsx b/apps/site/components/Containers/NavBar/index.tsx deleted file mode 100644 index 1ce06a1a5d0f6..0000000000000 --- a/apps/site/components/Containers/NavBar/index.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import Hamburger from '@heroicons/react/24/solid/Bars3Icon'; -import XMark from '@heroicons/react/24/solid/XMarkIcon'; -import * as Label from '@radix-ui/react-label'; -import { useState } from 'react'; -import type { FC, ComponentProps, HTMLAttributeAnchorTarget } from 'react'; - -import LanguageDropdown from '@/components/Common/LanguageDropDown'; -import { SearchButton } from '@/components/Common/Search'; -import ThemeToggle from '@/components/Common/ThemeToggle'; -import NavItem from '@/components/Containers/NavBar/NavItem'; -import GitHub from '@/components/Icons/Social/GitHub'; -import Link from '@/components/Link'; -import WithNodejsLogo from '@/components/withNodejsLogo'; -import type { FormattedMessage } from '@/types'; - -import style from './index.module.css'; - -const navInteractionIcons = { - show: <Hamburger className={style.navInteractionIcon} />, - close: <XMark className={style.navInteractionIcon} />, -}; - -type NavbarProps = { - navItems: Array<{ - text: FormattedMessage; - link: string; - target?: HTMLAttributeAnchorTarget | undefined; - }>; - languages: ComponentProps<typeof LanguageDropdown>; - onThemeTogglerClick: () => void; -}; - -const NavBar: FC<NavbarProps> = ({ - navItems, - languages, - onThemeTogglerClick, -}) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); - - return ( - <nav className={`${style.container}`}> - <div className={style.nodeIconAndMobileItemsToggler}> - <Link className={style.nodeIconWrapper} href="/" aria-label="Home"> - <WithNodejsLogo /> - </Link> - - <Label.Root - onClick={() => setIsMenuOpen(prev => !prev)} - className={style.sidebarItemTogglerLabel} - htmlFor="sidebarItemToggler" - > - {navInteractionIcons[isMenuOpen ? 'close' : 'show']} - </Label.Root> - </div> - - <input className="peer hidden" id="sidebarItemToggler" type="checkbox" /> - - <div className={`${style.main} peer-checked:flex`}> - <div className={style.navItems}> - {navItems.map(({ text, link, target }) => ( - <NavItem key={link} href={link} target={target}> - {text} - </NavItem> - ))} - </div> - - <div className={style.actionsWrapper}> - <SearchButton /> - - <ThemeToggle onClick={onThemeTogglerClick} /> - - <LanguageDropdown - onChange={languages.onChange} - availableLanguages={languages.availableLanguages} - currentLanguage={languages.currentLanguage} - /> - - <Link - className={style.ghIconWrapper} - href="https://github.com/nodejs/node" - aria-label="Node.js Github" - > - <GitHub /> - </Link> - </div> - </div> - </nav> - ); -}; - -export default NavBar; diff --git a/apps/site/components/Containers/Sidebar/SidebarGroup/index.module.css b/apps/site/components/Containers/Sidebar/SidebarGroup/index.module.css deleted file mode 100644 index 52f0d7507fadf..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarGroup/index.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.group { - @apply flex - w-full - flex-col - gap-2; -} - -.groupName { - @apply px-2 - py-1 - text-xs - font-semibold - text-neutral-800 - dark:text-neutral-600; -} - -.itemList { - @apply m-0 - flex - flex-col - items-start - gap-0.5 - p-0; -} diff --git a/apps/site/components/Containers/Sidebar/SidebarGroup/index.stories.tsx b/apps/site/components/Containers/Sidebar/SidebarGroup/index.stories.tsx deleted file mode 100644 index 3ee85d621bf0b..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarGroup/index.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import SidebarGroup from '@/components/Containers/Sidebar/SidebarGroup'; - -type Story = StoryObj<typeof SidebarGroup>; -type Meta = MetaObj<typeof SidebarGroup>; - -export const Default: Story = { - args: { - groupName: 'Example Group', - items: [ - { label: 'Item 1', link: '/item1' }, - { label: 'Item 2', link: '/item2' }, - { label: 'Item 3', link: '/item3' }, - ], - }, -}; - -export const CustomGroup: Story = { - args: { - groupName: 'Custom Group', - items: [ - { label: 'Custom Item 1', link: '/custom-item1' }, - { label: 'Custom Item 2', link: '/custom-item2' }, - ], - }, -}; - -export const EmptyGroup: Story = { - args: { - groupName: 'Empty Group', - items: [], - }, -}; - -export default { component: SidebarGroup } as Meta; diff --git a/apps/site/components/Containers/Sidebar/SidebarGroup/index.tsx b/apps/site/components/Containers/Sidebar/SidebarGroup/index.tsx deleted file mode 100644 index f22de23fa5408..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarGroup/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { ComponentProps, FC } from 'react'; - -import SidebarItem from '@/components/Containers/Sidebar/SidebarItem'; -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type SidebarGroupProps = { - groupName: FormattedMessage; - items: Array<ComponentProps<typeof SidebarItem>>; -}; - -const SidebarGroup: FC<SidebarGroupProps> = ({ groupName, items }) => ( - <section className={styles.group}> - <label className={styles.groupName}>{groupName}</label> - <ul className={styles.itemList}> - {items.map(({ label, link }) => ( - <SidebarItem key={link} label={label} link={link} /> - ))} - </ul> - </section> -); - -export default SidebarGroup; diff --git a/apps/site/components/Containers/Sidebar/SidebarItem/index.module.css b/apps/site/components/Containers/Sidebar/SidebarItem/index.module.css deleted file mode 100644 index 38c89b9fd6028..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarItem/index.module.css +++ /dev/null @@ -1,33 +0,0 @@ -.sideBarItem { - @apply flex - w-full - list-none - text-neutral-800 - dark:text-neutral-200; - - a { - @apply inline-flex - items-center - gap-2 - p-2; - } - - .label { - @apply font-regular - w-full - text-sm; - } - - .icon { - @apply size-3 - text-neutral-500 - dark:text-neutral-200; - } -} - -.active { - @apply rounded - bg-green-600 - text-white - dark:text-white; -} diff --git a/apps/site/components/Containers/Sidebar/SidebarItem/index.stories.tsx b/apps/site/components/Containers/Sidebar/SidebarItem/index.stories.tsx deleted file mode 100644 index 3ed6e4383e819..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarItem/index.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import SidebarItem from '@/components/Containers/Sidebar/SidebarItem'; - -type Story = StoryObj<typeof SidebarItem>; -type Meta = MetaObj<typeof SidebarItem>; - -export const Default: Story = { - args: { - label: 'Example Item', - link: '/example', - }, -}; - -export default { component: SidebarItem } as Meta; diff --git a/apps/site/components/Containers/Sidebar/SidebarItem/index.tsx b/apps/site/components/Containers/Sidebar/SidebarItem/index.tsx deleted file mode 100644 index 6650182a133d9..0000000000000 --- a/apps/site/components/Containers/Sidebar/SidebarItem/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; -import type { FC } from 'react'; - -import ActiveLink from '@/components/Common/ActiveLink'; -import type { FormattedMessage } from '@/types'; - -import styles from './index.module.css'; - -type SidebarItemProps = { - label: FormattedMessage; - link: string; -}; - -const SidebarItem: FC<SidebarItemProps> = ({ label, link }) => ( - <li className={styles.sideBarItem}> - <ActiveLink href={link} activeClassName={styles.active}> - <span className={styles.label}>{label}</span> - - {link.startsWith('http') && <ArrowUpRightIcon className={styles.icon} />} - </ActiveLink> - </li> -); - -export default SidebarItem; diff --git a/apps/site/components/Containers/Sidebar/index.module.css b/apps/site/components/Containers/Sidebar/index.module.css deleted file mode 100644 index 7324ae7ba9f29..0000000000000 --- a/apps/site/components/Containers/Sidebar/index.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.wrapper { - @apply flex - w-full - flex-col - items-start - gap-8 - overflow-auto - overflow-x-hidden - border-r-neutral-200 - bg-white - px-4 - py-6 - sm:border-r - md:max-w-xs - lg:px-6 - dark:border-r-neutral-900 - dark:bg-neutral-950; - - > section { - @apply hidden - sm:flex; - } - - > span { - @apply flex - w-full - sm:hidden; - } -} diff --git a/apps/site/components/Containers/Sidebar/index.stories.tsx b/apps/site/components/Containers/Sidebar/index.stories.tsx deleted file mode 100644 index d854bdb70e7b5..0000000000000 --- a/apps/site/components/Containers/Sidebar/index.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Sidebar from '@/components/Containers/Sidebar'; - -type Story = StoryObj<typeof Sidebar>; -type Meta = MetaObj<typeof Sidebar>; - -export const Default: Story = { - args: { - groups: [ - { - groupName: 'About Node.js', - items: [ - { - link: '/item1', - label: 'About Node.js', - }, - { - link: '/item2', - label: 'Project Governance', - }, - { - link: '/item3', - label: 'Releases', - }, - { - link: '/item4', - label: 'Branding', - }, - { - link: '/item5', - label: 'Privacy Policy', - }, - { - link: '/item6', - label: 'Security Reporting', - }, - ], - }, - { - groupName: 'Get Involved', - items: [ - { - link: '/item7', - label: 'Get Involved', - }, - { - link: '/item8', - label: 'Collab Summit', - }, - { - link: '/item9', - label: 'Contribute', - }, - { - link: '/item10', - label: 'Code of Conduct', - }, - ], - }, - { - groupName: 'Download', - items: [ - { - link: '/item11', - label: 'Download', - }, - { - link: '/item12', - label: 'Package Manager', - }, - { - link: '/item13', - label: 'Node.js Releases', - }, - ], - }, - ], - }, -}; - -export default { component: Sidebar } as Meta; diff --git a/apps/site/components/Containers/Sidebar/index.tsx b/apps/site/components/Containers/Sidebar/index.tsx deleted file mode 100644 index ced013a038b84..0000000000000 --- a/apps/site/components/Containers/Sidebar/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { ComponentProps, FC } from 'react'; - -import SidebarGroup from '@/components/Containers/Sidebar/SidebarGroup'; -import WithRouterSelect from '@/components/withRouterSelect'; -import { useClientContext } from '@/hooks/react-server'; - -import styles from './index.module.css'; - -type SidebarProps = { - groups: Array<ComponentProps<typeof SidebarGroup>>; -}; - -const SideBar: FC<SidebarProps> = ({ groups }) => { - const t = useTranslations(); - const { pathname } = useClientContext(); - - const selectItems = groups.map(({ items, groupName }) => ({ - label: groupName, - items: items.map(({ label, link }) => ({ value: link, label })), - })); - - const currentItem = selectItems - .map(item => item.items) - .flat() - .find(item => pathname === item.value); - - return ( - <aside className={styles.wrapper}> - {selectItems.length > 0 && ( - <WithRouterSelect - label={t('components.common.sidebar.title')} - values={selectItems} - defaultValue={currentItem?.value} - /> - )} - - {groups.map(({ groupName, items }) => ( - <SidebarGroup - key={groupName.toString()} - groupName={groupName} - items={items} - /> - ))} - </aside> - ); -}; - -export default SideBar; diff --git a/apps/site/components/Downloads/ChangelogModal/index.module.css b/apps/site/components/Downloads/ChangelogModal/index.module.css deleted file mode 100644 index 844d065eab38d..0000000000000 --- a/apps/site/components/Downloads/ChangelogModal/index.module.css +++ /dev/null @@ -1,99 +0,0 @@ -.overlay { - @apply fixed - inset-0 - flex - justify-center - bg-white/40 - backdrop-blur-[2px] - dark:bg-neutral-950/40; - - .content { - @apply relative - mx-4 - my-4 - inline-flex - w-full - flex-col - overflow-y-auto - rounded - border - border-neutral-200 - bg-white - p-6 - focus:outline-none - sm:my-20 - sm:p-8 - lg:max-w-[900px] - xl:p-12 - dark:border-neutral-900 - dark:bg-neutral-950; - } - - .close { - @apply absolute - right-3 - top-3 - block - size-6 - cursor-pointer - rounded - focus:outline-none - focus:ring-2 - focus:ring-neutral-200 - dark:focus:ring-neutral-900; - } - - .title { - @apply mb-2 - text-3xl - font-semibold - text-neutral-900 - dark:text-white; - } - - .description { - @apply font-regular - mb-4 - text-lg - text-neutral-800 - dark:text-neutral-200; - } - - .authors { - @apply mb-8 - flex - flex-wrap - items-center - gap-4; - - a { - @apply flex - items-center - gap-1 - text-xs - text-neutral-600 - underline - visited:text-neutral-600; - } - - svg { - @apply size-3 - text-neutral-600; - } - } - - .wrapper main { - @apply flex - flex-col - gap-4; - - pre { - @apply overflow-auto; - } - - code, - a { - @apply break-words; - } - } -} diff --git a/apps/site/components/Downloads/ChangelogModal/index.stories.tsx b/apps/site/components/Downloads/ChangelogModal/index.stories.tsx deleted file mode 100644 index a08e8bd299ee4..0000000000000 --- a/apps/site/components/Downloads/ChangelogModal/index.stories.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import { VFile } from 'vfile'; - -import ChangelogModal from '@/components/Downloads/ChangelogModal'; -import { MDXRenderer } from '@/components/mdxRenderer'; -import { compileMDX } from '@/next.mdx.compiler.mjs'; -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -type Story = StoryObj<typeof ChangelogModal>; -type Meta = MetaObj<typeof ChangelogModal>; - -const names = [ - 'ovflowd', - 'bmuenzenmeyer', - 'AugustinMauroy', - 'HinataKah0', - 'Harkunwar', - 'rodion-arr', - 'mikeesto', - 'bnb', - 'benhalverson', - 'aymen94', - 'shanpriyan', - 'Wai-Dung', - 'manishprivet', - 'araujogui', - 'thefossildev', -]; - -const children = ` -### Notable Changes - -#### Ada 2.0 - -Node.js v18.17.0 comes with the latest version of the URL parser, Ada. This update brings significant performance improvements -to URL parsing, including enhancements to the url.domainToASCII and url.domainToUnicode functions in node:url. - -Ada 2.0 has been integrated into the Node.js codebase, ensuring that all parts of the application can benefit from the -improved performance. Additionally, Ada 2.0 features a significant performance boost over its predecessor, Ada 1.0.4, -while also eliminating the need for the ICU requirement for URL hostname parsing. - -Contributed by Yagiz Nizipli and Daniel Lemire in [#47339](https://github.com/nodejs/node/pull/47339) - -#### Web Crypto API - -Web Crypto API functions' arguments are now coerced and validated as per their WebIDL definitions like in other Web Crypto API implementations. -This further improves interoperability with other implementations of Web Crypto API. - -Contributed by Filip Skokan in [#46067](https://github.com/nodejs/node/pull/46067) - -- **crypto**: - - update root certificates to NSS 3.89 (Node.js GitHub Bot) [#47659](https://github.com/nodejs/node/pull/47659) -- **dns**: - - **(SEMVER-MINOR)** expose getDefaultResultOrder (btea) [#46973](https://github.com/nodejs/node/pull/46973) -- **doc**: - - add ovflowd to collaborators (Claudio Wunder) [#47844](https://github.com/nodejs/node/pull/47844) - - add KhafraDev to collaborators (Matthew Aitken) [#47510](https://github.com/nodejs/node/pull/47510) -- **events**: - - **(SEMVER-MINOR)** add getMaxListeners method (Matthew Aitken) [#47039](https://github.com/nodejs/node/pull/47039) -- **fs**: - - **(SEMVER-MINOR)** add support for mode flag to specify the copy behavior (Tetsuharu Ohzeki) [#47084](https://github.com/nodejs/node/pull/47084) - - **(SEMVER-MINOR)** add recursive option to readdir and opendir (Ethan Arrowood) [#41439](https://github.com/nodejs/node/pull/41439) - - **(SEMVER-MINOR)** add support for mode flag to specify the copy behavior (Tetsuharu Ohzeki) [#47084](https://github.com/nodejs/node/pull/47084) - - **(SEMVER-MINOR)** implement byob mode for readableWebStream() (Debadree Chatterjee) [#46933](https://github.com/nodejs/node/pull/46933) -- **http**: - - **(SEMVER-MINOR)** prevent writing to the body when not allowed by HTTP spec (Gerrard Lindsay) [#47732](https://github.com/nodejs/node/pull/47732) - - **(SEMVER-MINOR)** remove internal error in assignSocket (Matteo Collina) [#47723](https://github.com/nodejs/node/pull/47723) - - **(SEMVER-MINOR)** add highWaterMark opt in http.createServer (HinataKah0) [#47405](https://github.com/nodejs/node/pull/47405) -- **lib**: - - **(SEMVER-MINOR)** add webstreams to Duplex.from() (Debadree Chatterjee) [#46190](https://github.com/nodejs/node/pull/46190) - - **(SEMVER-MINOR)** implement AbortSignal.any() (Chemi Atlow) [#47821](https://github.com/nodejs/node/pull/47821) -- **module**: - - change default resolver to not throw on unknown scheme (Gil Tayar) [#47824](https://github.com/nodejs/node/pull/47824) -- **node-api**: - - **(SEMVER-MINOR)** define version 9 (Chengzhong Wu) [#48151](https://github.com/nodejs/node/pull/48151) - - **(SEMVER-MINOR)** deprecate napi_module_register (Vladimir Morozov) [#46319](https://github.com/nodejs/node/pull/46319) -- **stream**: - - **(SEMVER-MINOR)** preserve object mode in compose (Raz Luvaton) [#47413](https://github.com/nodejs/node/pull/47413) - - **(SEMVER-MINOR)** add setter & getter for default highWaterMark (#46929) (Robert Nagy) [#46929](https://github.com/nodejs/node/pull/46929) -- **test**: - - unflake test-vm-timeout-escape-nexttick (Santiago Gimeno) [#48078](https://github.com/nodejs/node/pull/48078) -- **test_runner**: - - **(SEMVER-MINOR)** add shorthands to \`test\` (Chemi Atlow) [#47909](https://github.com/nodejs/node/pull/47909) - - **(SEMVER-MINOR)** support combining coverage reports (Colin Ihrig) [#47686](https://github.com/nodejs/node/pull/47686) - - **(SEMVER-MINOR)** execute before hook on test (Chemi Atlow) [#47586](https://github.com/nodejs/node/pull/47586) - - **(SEMVER-MINOR)** expose reporter for use in run api (Chemi Atlow) [#47238](https://github.com/nodejs/node/pull/47238) -- **tools**: - - update LICENSE and license-builder.sh (Santiago Gimeno) [#48078](https://github.com/nodejs/node/pull/48078) -- **url**: - - **(SEMVER-MINOR)** implement URL.canParse (Matthew Aitken) [#47179](https://github.com/nodejs/node/pull/47179) -- **wasi**: - - **(SEMVER-MINOR)** no longer require flag to enable wasi (Michael Dawson) [#47286](https://github.com/nodejs/node/pull/47286) - -### Commits - -- [[\`2ba08ac002\`](https://github.com/nodejs/node/commit/2ba08ac002)] - **benchmark**: use \`cluster.isPrimary\` instead of \`cluster.isMaster\` (Deokjin Kim) [#48002](https://github.com/nodejs/node/pull/48002) -- [[\`60ca69d96c\`](https://github.com/nodejs/node/commit/60ca69d96c)] - **benchmark**: add eventtarget creation bench (Rafael Gonzaga) [#47774](https://github.com/nodejs/node/pull/47774) -- [[\`d8233d96bb\`](https://github.com/nodejs/node/commit/d8233d96bb)] - **benchmark**: add a benchmark for \`defaultResolve\` (Antoine du Hamel) [#47543](https://github.com/nodejs/node/pull/47543) -- [[\`a1aabb6912\`](https://github.com/nodejs/node/commit/a1aabb6912)] - **benchmark**: fix invalid requirementsURL (Deokjin Kim) [#47378](https://github.com/nodejs/node/pull/47378) -- [[\`394c61caf9\`](https://github.com/nodejs/node/commit/394c61caf9)] - **bootstrap**: support namespaced builtins in snapshot scripts (Joyee Cheung) [#47467](https://github.com/nodejs/node/pull/47467) -- [[\`78972d4696\`](https://github.com/nodejs/node/commit/78972d4696)] - **worker**: support more cases when (de)serializing errors (Moshe Atlow) [#47925](https://github.com/nodejs/node/pull/47925) - -Windows 32-bit Installer: https://nodejs.org/dist/v18.17.0/node-v18.17.0-x86.msi \\ -Windows 64-bit Installer: https://nodejs.org/dist/v18.17.0/node-v18.17.0-x64.msi \\ -Windows 32-bit Binary: https://nodejs.org/dist/v18.17.0/win-x86/node.exe \\ -Windows 64-bit Binary: https://nodejs.org/dist/v18.17.0/win-x64/node.exe \\ -macOS 64-bit Installer: https://nodejs.org/dist/v18.17.0/node-v18.17.0.pkg \\ -macOS Apple Silicon 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-darwin-arm64.tar.gz \\ -macOS Intel 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-darwin-x64.tar.gz \\ -Linux 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-linux-x64.tar.xz \\ -Linux PPC LE 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-linux-ppc64le.tar.xz \\ -Linux s390x 64-bit Binary: _Coming soon_ \\ -AIX 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-aix-ppc64.tar.gz \\ -ARMv7 32-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-linux-armv7l.tar.xz \\ -ARMv8 64-bit Binary: https://nodejs.org/dist/v18.17.0/node-v18.17.0-linux-arm64.tar.xz \\ -Source Code: https://nodejs.org/dist/v18.17.0/node-v18.17.0.tar.gz \\ -Other release files: https://nodejs.org/dist/v18.17.0/ \\ -Documentation: https://nodejs.org/docs/v18.17.0/api/ - -### SHASUMS - -\`\`\` ------BEGIN PGP SIGNED MESSAGE----- -Hash: SHA256 - -b61952f8468fe50b24b46039ad795af4bbea3751ded7cb488ec52e5a332f91ce node-v18.17.0-aix-ppc64.tar.gz -19731ef427e77ad9c5f476eb62bfb02a7f179d3012feed0bbded62e45f23e679 node-v18.17.0-darwin-arm64.tar.gz -621cf884c4c27ddc595ff23f35ccd6fa1e827470581c30d31d779ba3cd6a162e node-v18.17.0-darwin-arm64.tar.xz -2f381442381f7fbde2ca644c3275bec9c9c2a8d361f467b40e39428acdd6ccff node-v18.17.0-darwin-x64.tar.gz -774734231d484d72e14f3327db6d7915abd3e2164ba577dd78affa5eade48a11 node-v18.17.0-darwin-x64.tar.xz -7c47c07521f8be7693f3b0436d2ef0cb5a490d070d1d9a3606decc55c39c41c4 node-v18.17.0-headers.tar.gz -1a7a3bbb7299f69e16a8ee2b327dd1c4811a9376bcafe41f8310467a9a9e3307 node-v18.17.0-headers.tar.xz -9d586f9d8b73a121b8a5438079c7106379aaae868f945a9f1755565607607944 node-v18.17.0-linux-arm64.tar.gz -fbd2904178ee47da6e0386bc9704a12b1f613da6ad194878a517d4a69ba56544 node-v18.17.0-linux-arm64.tar.xz -a9c3a56b3a407eca1fef7c688e6e7774cc492c9a5ae74f0baff2d8fe9b7b74c3 node-v18.17.0-linux-armv7l.tar.gz -a2f12295b2da1db23d6f48c5bfb5969e7bf26f663ca8256e1c246ca0ee44a71f node-v18.17.0-linux-armv7l.tar.xz -6da314c8ef1b6239290d9237ba14bd44cfd4382a6a59696a5f506c6c8dd353ad node-v18.17.0-linux-ppc64le.tar.gz -9fd9c215be8e54dcf8bbc42ba9e1342b05136be4bc12594ee40a29cedfaeaffd node-v18.17.0-linux-ppc64le.tar.xz -c39f8386ee916915949651cad3a735f988ec825c60fe591c0791b4883749bc8b node-v18.17.0-linux-s390x.tar.gz -876ca54c246d24e346d0c740fbb72c9fb7353369127f20492bc923ee6d0121db node-v18.17.0-linux-s390x.tar.xz -5c4a7fd9262c0c47bafab3442de6c3fed1602be3d243cb8cf11309a201955e75 node-v18.17.0-linux-x64.tar.gz -f36facda28c4d5ce76b3a1b4344e688d29d9254943a47f2f1909b1a10acb1959 node-v18.17.0-linux-x64.tar.xz -a4710e7da19464707582216557edcc5a15e29fca4a80599f89484cd867a66a73 node-v18.17.0.pkg -7aaa157c0219b5895a4bd51d9e9731e0d0eb0ab9424fdbdcf30d01b3e5fb74e9 node-v18.17.0.tar.gz -80c0faadf5ea39c213ccb9aa5c2432977a0f1b5a0b766852abd0de06f2770406 node-v18.17.0.tar.xz -daf06430be6380cd7416f0e9060b99bea821ceb72dacd55242ad84a5440cf45b node-v18.17.0-win-x64.7z -06e30b4e70b18d794651ef132c39080e5eaaa1187f938721d57edae2824f4e96 node-v18.17.0-win-x64.zip -d02327ddb3a6895a79b641fa4a456590bd7c8f0070a1915f2f76a70fd4ea7cc9 node-v18.17.0-win-x86.7z -6aef2e2ca6e0edf3cc85b5d8ae640caaab46e98da378c749d79a34fd78e0416e node-v18.17.0-win-x86.zip -dabdc4e683f0d0a835952ea5df61139fa6b679e25121d1941a2ae77d29e989f4 node-v18.17.0-x64.msi -724c4258aa878fd6286a41dc62a1128a10a918b5b3028de8d276126f3d1c5b1d node-v18.17.0-x86.msi -da74bcc1606026f05523c33e7882cc0ca4e0786a532bb80f932873eca9bdb431 win-x64/node.exe -fce36923d974ac8f9cea4bd68e56ab055797fd5b5ede5041b228d642a0d9bb40 win-x64/node.lib -221209e2d1defb5a59900e6a05e1c4b3d5bebca80048df4649436346a7944e59 win-x64/node_pdb.7z -792a2f132f5753a21c241629956ac4b0251baafbbf1afb0fcd96cf6c672727a8 win-x64/node_pdb.zip -da188adf811f97ba7da777dca949456936cc2ce1a540175f5c9e4f64169bddaf win-x86/node.exe -7982f49aac6ff5b4cfaa236793f62a8c18b0bc9f1f2903617b9fdafee377bb2b win-x86/node.lib -38c62a693d89e4b38863268f7286fc3fab8d5ce24bd4b7b3ed5a151b9f1b53b7 win-x86/node_pdb.7z -2da36082fae828d4faf45226775a7bee90f3d6e7e92cbf7a69c89b287a762f89 win-x86/node_pdb.zip ------BEGIN PGP SIGNATURE----- -iQIzBAEBCAAdFiEEdPEmArbxxOkT+qN606iWE2Q7YgEFAmS26kQACgkQ06iWE2Q7 -YgGjxRAAnK6iw0ZQI6CvfuJgJsaV7bj3Y8nj/CvMaj7o2CzgMLpY4r8aj5Mlt60U -K2exJOY+h3Z6CQExZITp+k2v8j07R+xiCEYt9K6UkZwxbCI4UJNePGPjphHwq6/R -dQk9i6GRd1lhhSYxqEqRSSptY9vFKfEczJgyUqWQGtNCX1hW15osvK6B/tZN4/bs -sYnTEImOkoFjz3GRj8PEMv42tF7aRUhC2E8Xsl0jZlt/n9GWe7LX3f2OJfczafJo -Q7bj670pUCHxpHxvjHsg/lWDuunWDvNIZSMFvR71VI3PIe45s2WoRO/jDNxJ9wyt -CBlfb08LEOnBHxV8au1GZQqvRaLedD51j9N7IwvQb3DDdq7T3msuIDzlZiByj5aZ -E2wGiVYetevuSogOf49CO99MexciDcCdwRSY79cg5Ar92Zl3xzrcErmXWsJd72p9 -AAdTZQKtTKih0k3YKtYBe0c4gMm8U8bfQ12RiVQPUJMnwYbOIZ3l/6omuKN458Hm -poxXU/aUN6KCNR/5UfGivKr8EVjjO8EvHj/txHCmiEz/YhM7dbHFBQdfCY/EWKap -IYUAiR4jIWhGYFS3dbwxhCZHTIPfIK3DtqvQBtU07wa1WSAkKKSMCgBHAKOlDwap -ZCVKLyezajjko28SugXGjegEjcY4o7v23XghhW6RAbEB6R8TZDo= -=Y5D8 ------END PGP SIGNATURE----- -\`\`\` -`; - -export const Default: Story = { - args: { - open: false, - heading: 'Node v18.17.0', - subheading: "2023-07-18, Version 18.17.0 'Hydrogen' (LTS), @danielleadams", - avatars: names.map(name => ({ - src: getGitHubAvatarUrl(name), - alt: name, - })), - children, - }, - render: (_, { loaded: { Content } }) => Content, - loaders: [ - async ({ args: { children, ...props } }) => { - const { MDXContent } = await compileMDX( - new VFile(children?.toString()), - 'md' - ); - - return { - Content: ( - <ChangelogModal {...props}> - <main> - <MDXRenderer Component={MDXContent} /> - </main> - </ChangelogModal> - ), - }; - }, - ], -}; - -export default { component: ChangelogModal } as Meta; diff --git a/apps/site/components/Downloads/ChangelogModal/index.tsx b/apps/site/components/Downloads/ChangelogModal/index.tsx deleted file mode 100644 index 3c2fe26a7cbec..0000000000000 --- a/apps/site/components/Downloads/ChangelogModal/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { ArrowUpRightIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import * as Dialog from '@radix-ui/react-dialog'; -import { useTranslations } from 'next-intl'; -import type { FC, PropsWithChildren, ComponentProps } from 'react'; - -import AvatarGroup from '@/components/Common/AvatarGroup'; -import Link from '@/components/Link'; - -import styles from './index.module.css'; - -type ChangelogModalProps = PropsWithChildren<{ - heading: string; - subheading: string; - avatars: ComponentProps<typeof AvatarGroup>['avatars']; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}>; - -const ChangelogModal: FC<ChangelogModalProps> = ({ - heading, - subheading, - avatars, - children, - open = false, - onOpenChange = () => {}, -}) => { - const t = useTranslations(); - - return ( - <Dialog.Root open={open} onOpenChange={onOpenChange}> - <Dialog.Portal> - <Dialog.Overlay className={styles.overlay}> - <Dialog.Content className={styles.content}> - <Dialog.Trigger className={styles.close}> - <XMarkIcon /> - </Dialog.Trigger> - - <Dialog.Title className={styles.title}>{heading}</Dialog.Title> - - <Dialog.Description className={styles.description}> - {subheading} - </Dialog.Description> - - <div className={styles.authors}> - <AvatarGroup avatars={avatars} isExpandable={false} /> - - <Link href="/about/get-involved"> - {t('components.downloads.changelogModal.startContributing')} - <ArrowUpRightIcon /> - </Link> - </div> - - <div className={styles.wrapper}>{children}</div> - - <Dialog.Close /> - </Dialog.Content> - </Dialog.Overlay> - </Dialog.Portal> - </Dialog.Root> - ); -}; - -export default ChangelogModal; diff --git a/apps/site/components/Downloads/DownloadButton/index.module.css b/apps/site/components/Downloads/DownloadButton/index.module.css index 8cb70e57be6c6..7cc12d7f700e3 100644 --- a/apps/site/components/Downloads/DownloadButton/index.module.css +++ b/apps/site/components/Downloads/DownloadButton/index.module.css @@ -1,3 +1,5 @@ +@reference "../../../styles/index.css"; + .downloadButton { @apply justify-center; diff --git a/apps/site/components/Downloads/DownloadButton/index.stories.tsx b/apps/site/components/Downloads/DownloadButton/index.stories.tsx deleted file mode 100644 index 84c68376a49a9..0000000000000 --- a/apps/site/components/Downloads/DownloadButton/index.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import DownloadButton from '@/components/Downloads/DownloadButton'; - -type Story = StoryObj<typeof DownloadButton>; -type Meta = MetaObj<typeof DownloadButton>; - -export const Default: Story = { - args: { - release: { - currentStart: '2023-04-18', - ltsStart: '2023-10-24', - maintenanceStart: '2024-10-22', - endOfLife: '2026-04-30', - status: 'LTS', - major: 20, - version: '20.11.0', - versionWithPrefix: 'v20.11.0', - codename: 'Iron', - isLts: true, - npm: '10.2.4', - v8: '11.3.244.8', - releaseDate: '2024-01-09', - modules: '115', - }, - children: 'Download Node.js', - }, -}; - -export default { component: DownloadButton } as Meta; diff --git a/apps/site/components/Downloads/DownloadButton/index.tsx b/apps/site/components/Downloads/DownloadButton/index.tsx index 342ff1a2dd1fb..8e8a2d9469e23 100644 --- a/apps/site/components/Downloads/DownloadButton/index.tsx +++ b/apps/site/components/Downloads/DownloadButton/index.tsx @@ -1,13 +1,13 @@ -'use client'; - import { CloudArrowDownIcon } from '@heroicons/react/24/outline'; import classNames from 'classnames'; -import type { FC, PropsWithChildren } from 'react'; -import Button from '@/components/Common/Button'; -import { useDetectOS } from '@/hooks'; -import type { NodeRelease } from '@/types'; -import { getNodeDownloadUrl } from '@/util/getNodeDownloadUrl'; +import { getClientContext } from '#site/client-context'; +import Button from '#site/components/Common/Button'; +import { getNodeDownloadUrl } from '#site/util/url'; +import { getUserPlatform } from '#site/util/userAgent'; + +import type { NodeRelease } from '#site/types'; +import type { FC, PropsWithChildren } from 'react'; import styles from './index.module.css'; @@ -17,8 +17,10 @@ const DownloadButton: FC<PropsWithChildren<DownloadButtonProps>> = ({ release: { versionWithPrefix }, children, }) => { - const { os, bitness } = useDetectOS(); - const downloadLink = getNodeDownloadUrl(versionWithPrefix, os, bitness); + const { os, bitness, architecture } = getClientContext(); + + const platform = getUserPlatform(architecture, bitness); + const downloadLink = getNodeDownloadUrl({ versionWithPrefix, os, platform }); return ( <> diff --git a/apps/site/components/Downloads/DownloadLink.tsx b/apps/site/components/Downloads/DownloadLink.tsx index bedab21bebf7f..7ec914d6f2eed 100644 --- a/apps/site/components/Downloads/DownloadLink.tsx +++ b/apps/site/components/Downloads/DownloadLink.tsx @@ -1,21 +1,30 @@ -'use client'; +import { getClientContext } from '#site/client-context'; +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import { getNodeDownloadUrl } from '#site/util/url'; +import { getUserPlatform } from '#site/util/userAgent'; +import type { DownloadKind, NodeRelease } from '#site/types'; import type { FC, PropsWithChildren } from 'react'; -import { useDetectOS } from '@/hooks'; -import type { NodeRelease } from '@/types'; -import { getNodeDownloadUrl } from '@/util/getNodeDownloadUrl'; - -type DownloadLinkProps = { release: NodeRelease }; +type DownloadLinkProps = { release: NodeRelease; kind?: DownloadKind }; const DownloadLink: FC<PropsWithChildren<DownloadLinkProps>> = ({ release: { versionWithPrefix }, + kind = 'installer', children, }) => { - const { os, bitness } = useDetectOS(); - const downloadLink = getNodeDownloadUrl(versionWithPrefix, os, bitness); + const { os, bitness, architecture } = getClientContext(); + + const platform = getUserPlatform(architecture, bitness); + + const downloadLink = getNodeDownloadUrl({ + versionWithPrefix, + os, + platform, + kind, + }); - return <a href={downloadLink}>{children}</a>; + return <LinkWithArrow href={downloadLink}>{children}</LinkWithArrow>; }; export default DownloadLink; diff --git a/apps/site/components/Downloads/DownloadReleasesTable.tsx b/apps/site/components/Downloads/DownloadReleasesTable.tsx deleted file mode 100644 index 306b6ab29df49..0000000000000 --- a/apps/site/components/Downloads/DownloadReleasesTable.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { getTranslations } from 'next-intl/server'; -import type { FC } from 'react'; - -import getReleaseData from '@/next-data/releaseData'; -import { getNodeApiLink } from '@/util/getNodeApiLink'; -import { getNodeJsChangelog } from '@/util/getNodeJsChangelog'; - -// This is a React Async Server Component -// Note that Hooks cannot be used in a RSC async component -// Async Components do not get re-rendered at all. -const DownloadReleasesTable: FC = async () => { - const releaseData = await getReleaseData(); - - const t = await getTranslations(); - - return ( - <table id="tbVersions" className="download-table full-width"> - <thead> - <tr> - <th>Node.js Version</th> - <th>Codename</th> - <th>Release Date</th> - <th colSpan={2}>npm</th> - </tr> - </thead> - <tbody> - {releaseData.map(release => ( - <tr key={release.major}> - <td data-label="Version">v{release.version}</td> - <td data-label="LTS">{release.codename || '-'}</td> - <td data-label="Date"> - <time>{release.releaseDate}</time> - </td> - <td data-label="npm">v{release.npm}</td> - <td className="download-table-last"> - <a - href={`https://nodejs.org/download/release/${release.versionWithPrefix}/`} - > - {t('components.downloadReleasesTable.releases')} - </a> - <a href={getNodeJsChangelog(release.versionWithPrefix)}> - {t('components.downloadReleasesTable.changelog')} - </a> - <a href={getNodeApiLink(release.versionWithPrefix)}> - {t('components.downloadReleasesTable.docs')} - </a> - </td> - </tr> - ))} - </tbody> - </table> - ); -}; - -export default DownloadReleasesTable; diff --git a/apps/site/components/Downloads/DownloadsTable/index.tsx b/apps/site/components/Downloads/DownloadsTable/index.tsx new file mode 100644 index 0000000000000..9519dc0e785e4 --- /dev/null +++ b/apps/site/components/Downloads/DownloadsTable/index.tsx @@ -0,0 +1,48 @@ +import { useTranslations } from 'next-intl'; + +import Link from '#site/components/Link'; +import { OperatingSystemLabel } from '#site/util/download'; + +import type { DownloadArtifact } from '#site/types'; +import type { FC } from 'react'; + +type DownloadsTableProps = { + source: Array<DownloadArtifact>; +}; + +const DownloadsTable: FC<DownloadsTableProps> = ({ source }) => { + const t = useTranslations(); + + return ( + <table> + <thead> + <tr> + <th>{t('components.downloadsTable.fileName')}</th> + <th className="md:w-28"> + {t('components.downloadsTable.operatingSystem')} + </th> + <th className="md:w-28"> + {t('components.downloadsTable.architecture')} + </th> + </tr> + </thead> + <tbody> + {source.map(release => ( + <tr key={`${release.fileName}-${release.architecture}`}> + <td data-label={t('components.downloadsTable.fileName')}> + <Link href={release.url}>{release.fileName}</Link> + </td> + <td data-label={t('components.downloadsTable.operatingSystem')}> + {OperatingSystemLabel[release.os]} + </td> + <td data-label={t('components.downloadsTable.architecture')}> + {release.architecture} + </td> + </tr> + ))} + </tbody> + </table> + ); +}; + +export default DownloadsTable; diff --git a/apps/site/components/Downloads/Release/BitnessDropdown.tsx b/apps/site/components/Downloads/Release/BitnessDropdown.tsx deleted file mode 100644 index e6ddd46e6e822..0000000000000 --- a/apps/site/components/Downloads/Release/BitnessDropdown.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; -import { useEffect, useContext, useMemo } from 'react'; -import semVer from 'semver'; - -import Select from '@/components/Common/Select'; -import { useDetectOS } from '@/hooks/react-client'; -import { ReleaseContext } from '@/providers/releaseProvider'; -import { bitnessItems, formatDropdownItems } from '@/util/downloadUtils'; -import { getUserBitnessByArchitecture } from '@/util/getUserBitnessByArchitecture'; - -const parseNumericBitness = (bitness: string) => - /^\d+$/.test(bitness) ? Number(bitness) : bitness; - -const BitnessDropdown: FC = () => { - const { bitness: userBitness, architecture: userArchitecture } = - useDetectOS(); - const { bitness, os, release, setBitness } = useContext(ReleaseContext); - const t = useTranslations(); - - // we also reset the bitness when the OS changes, because different OSs have - // different bitnesses available - - useEffect(() => { - setBitness(getUserBitnessByArchitecture(userArchitecture, userBitness)); - // we shouldn't update the effect on setter state change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userArchitecture, userBitness]); - - // @TODO: We should have a proper utility that gives - // disabled OSs, Platforms, based on specific criteria - // this can be an optimisation for the future - // to remove this logic from this component - const disabledItems = useMemo(() => { - const disabledItems = []; - - if (os === 'WIN' && semVer.satisfies(release.version, '< 19.9.0')) { - disabledItems.push('arm64'); - } - - if (os === 'LINUX' && semVer.satisfies(release.version, '< 4.0.0')) { - disabledItems.push('arm64', 'armv7l'); - } - - if (os === 'LINUX' && semVer.satisfies(release.version, '< 4.4.0')) { - disabledItems.push('ppc64le'); - } - - if (os === 'LINUX' && semVer.satisfies(release.version, '< 6.6.0')) { - disabledItems.push('s390x'); - } - - if (os === 'AIX' && semVer.satisfies(release.version, '< 6.7.0')) { - disabledItems.push('ppc64'); - } - - return disabledItems; - }, [os, release.version]); - - // @TODO: We should have a proper utility that gives - // disabled OSs, Platforms, based on specific criteria - // this can be an optimisation for the future - // to remove this logic from this component - useEffect(() => { - const mappedBitnessValues = bitnessItems[os].map(({ value }) => value); - - const currentBitnessExcluded = - // Different OSs support different Bitnessess, hence we should also check - // if besides the current bitness not being supported for a given release version - // we also should check if it is not supported by the OS - disabledItems.includes(String(bitness)) || - !mappedBitnessValues.includes(String(bitness)); - - const nonExcludedBitness = mappedBitnessValues.find( - bitness => !disabledItems.includes(bitness) - ); - - if (currentBitnessExcluded && nonExcludedBitness) { - // We set it as a Number for cases where it is 64 or 86 otherwise we are - // setting it as a string (ARMv7, ARMv6, etc.) - const numericBitness = Number(nonExcludedBitness); - - setBitness( - numericBitness.toString() === nonExcludedBitness - ? numericBitness - : nonExcludedBitness - ); - } - // we shouldn't react when "actions" change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [os, disabledItems]); - - return ( - <Select - values={formatDropdownItems({ - items: bitnessItems[os], - disabledItems, - })} - ariaLabel={t('layouts.download.dropdown.bitness')} - defaultValue={String(bitness)} - onChange={bitness => setBitness(parseNumericBitness(bitness))} - className="w-28" - inline={true} - /> - ); -}; - -export default BitnessDropdown; diff --git a/apps/site/components/Downloads/Release/BlogPostLink.tsx b/apps/site/components/Downloads/Release/BlogPostLink.tsx index d9d1f90d60aad..804201925cc49 100644 --- a/apps/site/components/Downloads/Release/BlogPostLink.tsx +++ b/apps/site/components/Downloads/Release/BlogPostLink.tsx @@ -1,18 +1,17 @@ 'use client'; -import type { FC, PropsWithChildren } from 'react'; -import { useContext } from 'react'; +import { use } from 'react'; + +import Link from '#site/components/Link'; +import { ReleaseContext } from '#site/providers/releaseProvider'; -import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow'; -import { ReleaseContext } from '@/providers/releaseProvider'; +import type { FC, PropsWithChildren } from 'react'; const BlogPostLink: FC<PropsWithChildren> = ({ children }) => { - const { release } = useContext(ReleaseContext); + const { release } = use(ReleaseContext); const version = release.versionWithPrefix; - return ( - <LinkWithArrow href={`/blog/release/${version}`}>{children}</LinkWithArrow> - ); + return <Link href={`/blog/release/${version}`}>{children}</Link>; }; export default BlogPostLink; diff --git a/apps/site/components/Downloads/Release/ChangelogLink.tsx b/apps/site/components/Downloads/Release/ChangelogLink.tsx index 8fde7046bfe4b..146fa94f65bc7 100644 --- a/apps/site/components/Downloads/Release/ChangelogLink.tsx +++ b/apps/site/components/Downloads/Release/ChangelogLink.tsx @@ -1,22 +1,19 @@ 'use client'; -import type { FC, PropsWithChildren } from 'react'; -import { useContext } from 'react'; +import { use } from 'react'; + +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import { BASE_CHANGELOG_URL } from '#site/next.constants.mjs'; +import { ReleaseContext } from '#site/providers/releaseProvider'; -import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow'; -import { ReleaseContext } from '@/providers/releaseProvider'; +import type { FC, PropsWithChildren } from 'react'; const ChangelogLink: FC<PropsWithChildren> = ({ children }) => { - const { modalOpen, setModalOpen } = useContext(ReleaseContext); + const { release } = use(ReleaseContext); return ( - <LinkWithArrow asChild> - <button - className="anchor cursor-pointer" - onClick={() => setModalOpen(!modalOpen)} - > - {children} - </button> + <LinkWithArrow href={`${BASE_CHANGELOG_URL}${release.version}`}> + {children} </LinkWithArrow> ); }; diff --git a/apps/site/components/Downloads/Release/DownloadButton.tsx b/apps/site/components/Downloads/Release/DownloadButton.tsx deleted file mode 100644 index 706111c45803b..0000000000000 --- a/apps/site/components/Downloads/Release/DownloadButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { CloudArrowDownIcon } from '@heroicons/react/24/outline'; -import { useTranslations } from 'next-intl'; -import { useContext } from 'react'; -import type { FC } from 'react'; - -import Button from '@/components/Common/Button'; -import { ReleaseContext } from '@/providers/releaseProvider'; -import { getNodeDownloadUrl } from '@/util/getNodeDownloadUrl'; - -type DownloadButtonProps = { kind: 'installer' | 'binary' | 'source' }; - -const DownloadButton: FC<DownloadButtonProps> = ({ kind = 'installer' }) => { - const t = useTranslations(); - const { release, os, bitness } = useContext(ReleaseContext); - - const version = release.versionWithPrefix; - const url = getNodeDownloadUrl(version, os, bitness, kind); - - return ( - <div className="mb-2 mt-6"> - <Button href={url} disabled={!version}> - <CloudArrowDownIcon /> - - {t('layouts.download.buttons.prebuilt', { version })} - </Button> - </div> - ); -}; - -export default DownloadButton; diff --git a/apps/site/components/Downloads/Release/DownloadLink.tsx b/apps/site/components/Downloads/Release/DownloadLink.tsx new file mode 100644 index 0000000000000..ad915a68a7eac --- /dev/null +++ b/apps/site/components/Downloads/Release/DownloadLink.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { use } from 'react'; + +import DownloadLinkBase from '#site/components/Downloads/DownloadLink'; +import { ReleaseContext } from '#site/providers/releaseProvider'; + +import type { DownloadKind } from '#site/types/download'; +import type { FC, PropsWithChildren } from 'react'; + +type DownloadLinkProps = { kind?: DownloadKind }; + +const DownloadLink: FC<PropsWithChildren<DownloadLinkProps>> = ({ + kind = 'installer', + children, +}) => { + const { release } = use(ReleaseContext); + + return ( + <DownloadLinkBase release={release} kind={kind}> + {children} + </DownloadLinkBase> + ); +}; + +export default DownloadLink; diff --git a/apps/site/components/Downloads/Release/InstallationMethodDropdown.tsx b/apps/site/components/Downloads/Release/InstallationMethodDropdown.tsx new file mode 100644 index 0000000000000..4cc89b2149d2a --- /dev/null +++ b/apps/site/components/Downloads/Release/InstallationMethodDropdown.tsx @@ -0,0 +1,90 @@ +'use client'; + +import Select from '@node-core/ui-components/Common/Select'; +import { useTranslations } from 'next-intl'; +import { use, useEffect, useMemo } from 'react'; + +import { ReleaseContext } from '#site/providers/releaseProvider'; +import { nextItem, INSTALL_METHODS, parseCompat } from '#site/util/download'; + +import type { InstallationMethod } from '#site/types/release'; +import type { FC } from 'react'; + +const InstallationMethodDropdown: FC = () => { + const release = use(ReleaseContext); + const t = useTranslations(); + + // We parse the compatibility of the dropdown items + const parsedInstallMethods = useMemo( + () => parseCompat(INSTALL_METHODS, release), + // We only want to react on the change of the OS and Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.os, release.version] + ); + + // We group Platforms on the Platform Dropdown to provide the User + // understanding of what is recommended/official and what is not. + const grouppedMethods = useMemo( + () => [ + { + label: t('layouts.download.dropdown.platformGroups.official'), + items: parsedInstallMethods.filter(({ recommended }) => recommended), + }, + { + label: t('layouts.download.dropdown.platformGroups.unofficial'), + items: parsedInstallMethods.filter(({ recommended }) => !recommended), + }, + ], + // We only want to react on the change of the parsedPlatforms + // eslint-disable-next-line @eslint-react/exhaustive-deps + [parsedInstallMethods] + ); + + useEffect(() => { + // We should only define the initial Platform if the current platform is empty + // (aka has not yet been set) and the OS has finished loading (in the sense that) + // `detectOS` has finished running and decided what platform we are running on. + if (release.os !== 'LOADING' && release.installMethod === '') { + const installationMethod = + // Sets either the utmost recommended platform or the first non-disabled one + // Note that the first item of groupped platforms is always the recommended one + nextItem<InstallationMethod | ''>('', grouppedMethods[0].items) || + nextItem<InstallationMethod | ''>('', parsedInstallMethods); + + // This will never return an empty string as there should always be an item + // when the OS has finished loading for a given installation method + release.setInstallMethod(installationMethod as InstallationMethod); + } + // eslint-disable-next-line @eslint-react/exhaustive-deps + }, [parsedInstallMethods, release.installMethod, release.os]); + + // We set the Platform to the next available platform when the current + // one is not valid anymore due to OS or Version changes + useEffect( + () => { + if (release.os !== 'LOADING' && release.installMethod !== '') { + release.setInstallMethod( + nextItem(release.installMethod, parsedInstallMethods) + ); + } + }, + // We only want to react on the change of the OS and Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.os, release.version] + ); + + return ( + <Select<InstallationMethod | ''> + values={grouppedMethods} + value={release.installMethod} + loading={release.os === 'LOADING' || release.installMethod === ''} + ariaLabel={t('layouts.download.dropdown.platform')} + onChange={platform => platform && release.setInstallMethod(platform)} + className="min-w-28" + dropdownClassName="!max-h-none" + inline={true} + /> + ); +}; + +export default InstallationMethodDropdown; diff --git a/apps/site/components/Downloads/Release/LinkWithArrow.tsx b/apps/site/components/Downloads/Release/LinkWithArrow.tsx deleted file mode 100644 index 4910383ad52d3..0000000000000 --- a/apps/site/components/Downloads/Release/LinkWithArrow.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; -import type { SlotProps } from '@radix-ui/react-slot'; -import { Slot } from '@radix-ui/react-slot'; -import type { ComponentProps, FC, PropsWithChildren } from 'react'; - -import Link from '@/components/Link'; - -type LinkWithArrowProps = - | ({ asChild?: false } & ComponentProps<typeof Link>) - | ({ asChild: true } & SlotProps); - -const LinkWithArrow: FC<PropsWithChildren<LinkWithArrowProps>> = ({ - children, - asChild = false, - ...props -}) => { - const Comp = asChild ? Slot : Link; - - return ( - <Comp {...props}> - <> - {children} - <ArrowUpRightIcon className="ml-1 inline w-3 fill-neutral-600 dark:fill-white" /> - </> - </Comp> - ); -}; - -export default LinkWithArrow; diff --git a/apps/site/components/Downloads/Release/NpmLink.tsx b/apps/site/components/Downloads/Release/NpmLink.tsx deleted file mode 100644 index bdf65fe1d40b3..0000000000000 --- a/apps/site/components/Downloads/Release/NpmLink.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; - -import { useContext } from 'react'; -import type { FC } from 'react'; - -import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow'; -import { ReleaseContext } from '@/providers/releaseProvider'; - -const NpmLink: FC = () => { - const { release } = useContext(ReleaseContext); - - return ( - <LinkWithArrow - href={`https://github.com/npm/cli/releases/tag/v${release.npm}`} - > - npm ({release.npm}) - </LinkWithArrow> - ); -}; - -export default NpmLink; diff --git a/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx b/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx index 7cf9933a89432..b559a7b046373 100644 --- a/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx +++ b/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx @@ -1,69 +1,63 @@ 'use client'; +import Select from '@node-core/ui-components/Common/Select'; import { useTranslations } from 'next-intl'; -import { useContext, useEffect } from 'react'; -import type { FC } from 'react'; +import { use, useEffect, useMemo } from 'react'; + +import useClientContext from '#site/hooks/useClientContext'; +import { ReleaseContext } from '#site/providers/releaseProvider'; +import { nextItem, OPERATING_SYSTEMS, parseCompat } from '#site/util/download'; -import Select from '@/components/Common/Select'; -import Apple from '@/components/Icons/Platform/Apple'; -import Aix from '@/components/Icons/Platform/Generic'; -import Linux from '@/components/Icons/Platform/Linux'; -import Microsoft from '@/components/Icons/Platform/Microsoft'; -import { useDetectOS } from '@/hooks/react-client'; -import { ReleaseContext } from '@/providers/releaseProvider'; -import type { UserOS } from '@/types/userOS'; -import { - formatDropdownItems, - operatingSystemItems, -} from '@/util/downloadUtils'; +import type { OperatingSystem } from '#site/types/userAgent'; +import type { FC } from 'react'; -type OperatingSystemDropdownProps = { exclude?: Array<UserOS> }; +type OperatingSystemDropdownProps = { exclude?: Array<OperatingSystem> }; -const OperatingSystemDropdown: FC<OperatingSystemDropdownProps> = ({ - exclude = [], -}) => { - const { os: userOS } = useDetectOS(); - const { os, setOS } = useContext(ReleaseContext); +const OperatingSystemDropdown: FC<OperatingSystemDropdownProps> = () => { + const { os } = useClientContext(); + const release = use(ReleaseContext); const t = useTranslations(); - // we shouldn't react when "actions" change - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setOS(userOS), [userOS]); - - // @TODO: We should have a proper utility that gives - // disabled OSs, Platforms, based on specific criteria - // this can be an optimisation for the future - // to remove this logic from this component useEffect(() => { - const currentOSExcluded = exclude.includes(os); + if (os !== 'LOADING') { + release.setOS(os); + } + // Reacts on Client Context change of OS + // Only this Hook is allowed to bypass the `setOS` from above + // As this Hook is what defined the initial OS state + // eslint-disable-next-line @eslint-react/exhaustive-deps + }, [os]); - const nonExcludedOS = operatingSystemItems - .map(({ value }) => value) - .find(os => !exclude.includes(os)); + // We parse the compatibility of the dropdown items + const parsedOperatingSystems = useMemo( + () => parseCompat(OPERATING_SYSTEMS, release), + // We only want to react on the change of the Install Method and Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.installMethod, release.version] + ); - if (currentOSExcluded && nonExcludedOS) { - setOS(nonExcludedOS); - } - // we shouldn't react when "actions" change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [os, exclude]); + // We set the OS to the next available OS when the current + // one is not valid anymore due to Version changes + useEffect( + () => { + if (release.os !== 'LOADING') { + release.setOS(nextItem(release.os, parsedOperatingSystems)); + } + }, + // We only want to react on the change of the Version, Install Method and OS + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.installMethod, release.version, release.os] + ); return ( - <Select - values={formatDropdownItems({ - items: operatingSystemItems, - disabledItems: exclude, - icons: { - WIN: <Microsoft width={16} height={16} />, - MAC: <Apple width={16} height={16} />, - LINUX: <Linux width={16} height={16} />, - AIX: <Aix width={16} height={16} />, - }, - })} + <Select<OperatingSystem> + values={parsedOperatingSystems} + value={release.os !== 'LOADING' ? release.os : undefined} + loading={release.os === 'LOADING'} + placeholder={t('layouts.download.dropdown.unknown')} ariaLabel={t('layouts.download.dropdown.os')} - defaultValue={os} - onChange={value => setOS(value as UserOS)} - className="w-[8.5rem]" + onChange={value => release.setOS(value)} + className="min-w-[8.5rem]" inline={true} /> ); diff --git a/apps/site/components/Downloads/Release/PackageManagerDropdown.tsx b/apps/site/components/Downloads/Release/PackageManagerDropdown.tsx new file mode 100644 index 0000000000000..8915c29d0dbb8 --- /dev/null +++ b/apps/site/components/Downloads/Release/PackageManagerDropdown.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Select from '@node-core/ui-components/Common/Select'; +import { useTranslations } from 'next-intl'; +import { use, useEffect, useMemo } from 'react'; + +import { ReleaseContext } from '#site/providers/releaseProvider'; +import { nextItem, PACKAGE_MANAGERS, parseCompat } from '#site/util/download'; + +import type { PackageManager } from '#site/types/release'; +import type { FC } from 'react'; + +const PackageManagerDropdown: FC = () => { + const release = use(ReleaseContext); + const t = useTranslations(); + + // We parse the compatibility of the dropdown items + const parsedPackageManagers = useMemo( + () => parseCompat(PACKAGE_MANAGERS, release), + // We only want to react on the change of the Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.version] + ); + + // We set the Package Manager to the next available Package Manager when the current + // one is not valid anymore due to Version changes + useEffect( + () => + release.setPackageManager( + nextItem(release.packageManager, parsedPackageManagers) + ), + // We only want to react on the change of the Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.version, release.packageManager] + ); + + return ( + <Select<PackageManager> + values={parsedPackageManagers} + value={release.packageManager} + loading={release.os === 'LOADING' || release.installMethod === ''} + ariaLabel={t('layouts.download.dropdown.packageManager')} + onChange={manager => release.setPackageManager(manager)} + className="min-w-28" + inline={true} + /> + ); +}; + +export default PackageManagerDropdown; diff --git a/apps/site/components/Downloads/Release/PlatformDropdown.tsx b/apps/site/components/Downloads/Release/PlatformDropdown.tsx index a4839ae966f42..3285d32fdb21c 100644 --- a/apps/site/components/Downloads/Release/PlatformDropdown.tsx +++ b/apps/site/components/Downloads/Release/PlatformDropdown.tsx @@ -1,85 +1,92 @@ 'use client'; + +import Select from '@node-core/ui-components/Common/Select'; import { useTranslations } from 'next-intl'; -import { useContext, useEffect, useMemo } from 'react'; -import type { FC } from 'react'; +import { useEffect, use, useMemo, useState } from 'react'; -import Select from '@/components/Common/Select'; -import Choco from '@/components/Icons/Platform/Choco'; -import Docker from '@/components/Icons/Platform/Docker'; -import FNM from '@/components/Icons/Platform/FNM'; -import Homebrew from '@/components/Icons/Platform/Homebrew'; -import NVM from '@/components/Icons/Platform/NVM'; -import { ReleaseContext } from '@/providers/releaseProvider'; -import type { PackageManager } from '@/types/release'; -import { formatDropdownItems, platformItems } from '@/util/downloadUtils'; +import useClientContext from '#site/hooks/useClientContext'; +import { ReleaseContext } from '#site/providers/releaseProvider'; +import { PLATFORMS, nextItem, parseCompat } from '#site/util/download'; +import { getUserPlatform } from '#site/util/userAgent'; -const supportedHomebrewVersions = ['LTS', 'Current']; +import type { Platform } from '#site/types/userAgent'; +import type { FC } from 'react'; const PlatformDropdown: FC = () => { - const { release, os, platform, setPlatform } = useContext(ReleaseContext); - const t = useTranslations(); - - // @TODO: We should have a proper utility that gives - // disabled OSs, Platforms, based on specific criteria - // this can be an optimisation for the future - // to remove this logic from this component - const disabledItems = useMemo(() => { - const disabledItems = []; + const { architecture, bitness } = useClientContext(); - if (os === 'WIN') { - disabledItems.push('BREW', 'NVM'); - } + const release = use(ReleaseContext); - if (os === 'LINUX' || os === 'MAC') { - disabledItems.push('CHOCO'); - } + const t = useTranslations(); - const releaseSupportsHomebrew = supportedHomebrewVersions.includes( - release.status - ); + // Compute the platform during render so it's available to both `useEffect` calls below in the same cycle + // (avoiding race conditions when architecture detection and OS detection arrive in the same batch) + const currentPlatform = + architecture && bitness ? getUserPlatform(architecture, bitness) : null; - if (!releaseSupportsHomebrew) { - disabledItems.push('BREW'); - } + // Track whether the user has manually selected a platform via the dropdown. + // When true the OS/version effect will respect their choice instead of + // resetting to the auto-detected value. + const [userHasSelectedPlatform, setUserHasSelectedPlatform] = useState(false); - return disabledItems; - }, [os, release.status]); + useEffect( + () => { + if (currentPlatform) { + // eslint-disable-next-line @eslint-react/set-state-in-effect + setUserHasSelectedPlatform(false); + release.setPlatform(currentPlatform); + } + }, + // Only react on the change of the Client Context Architecture and Bitness + // eslint-disable-next-line @eslint-react/exhaustive-deps + [architecture, bitness] + ); - // @TODO: We should have a proper utility that gives - // disabled OSs, Platforms, based on specific criteria - // this can be an optimisation for the future - // to remove this logic from this component - useEffect(() => { - const currentPlatformExcluded = disabledItems.includes(platform); + // We parse the compatibility of the dropdown items + const parsedPlatforms = useMemo( + () => + // We only want to parse the compatibility when the OS has finished loading + // Otherwise, we would be parsing the compatibility of an empty array + release.os !== 'LOADING' + ? parseCompat(PLATFORMS[release.os], release) + : [], + // We only want to react on the change of the OS, Platform, and Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.os, release.version] + ); - const nonExcludedPlatform = platformItems - .map(({ value }) => value) - .find(platform => !disabledItems.includes(platform)); + // We set the Platform to the next available Architecture when the current + // one is not valid anymore due to OS or Version changes + useEffect( + () => { + if (release.os !== 'LOADING' && release.platform !== '') { + // If the user has not manually selected a platform and there is a currently + // auto-detected one then use it otherwise fallback to the current release platform + const basePlatform = + !userHasSelectedPlatform && currentPlatform + ? currentPlatform + : release.platform; - if (currentPlatformExcluded && nonExcludedPlatform) { - setPlatform(nonExcludedPlatform); - } - // we shouldn't react when "actions" change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [release.status, disabledItems, platform]); + release.setPlatform(nextItem(basePlatform, parsedPlatforms)); + } + }, + // We only want to react on the change of the OS and Version + // eslint-disable-next-line @eslint-react/exhaustive-deps + [release.os, release.version] + ); return ( - <Select - values={formatDropdownItems({ - items: platformItems, - icons: { - NVM: <NVM width={16} height={16} />, - FNM: <FNM width={16} height={16} />, - BREW: <Homebrew width={16} height={16} />, - DOCKER: <Docker width={16} height={16} />, - CHOCO: <Choco width={16} height={16} />, - }, - disabledItems, - })} + <Select<Platform> + values={parsedPlatforms} + value={release.platform !== '' ? release.platform : undefined} + loading={release.os === 'LOADING' || release.platform === ''} + placeholder={t('layouts.download.dropdown.unknown')} ariaLabel={t('layouts.download.dropdown.platform')} - defaultValue={platform} - onChange={platform => setPlatform(platform as PackageManager)} - className="w-28" + onChange={platform => { + setUserHasSelectedPlatform(true); + release.setPlatform(platform); + }} + className="min-w-28" inline={true} /> ); diff --git a/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx b/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx new file mode 100644 index 0000000000000..ff3849bbd8b9f --- /dev/null +++ b/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { CloudArrowDownIcon } from '@heroicons/react/24/outline'; +import Skeleton from '@node-core/ui-components/Common/Skeleton'; +import { useTranslations } from 'next-intl'; +import { use } from 'react'; + +import Button from '#site/components/Common/Button'; +import { ReleaseContext } from '#site/providers/releaseProvider'; +import { + OS_NOT_SUPPORTING_INSTALLERS, + OperatingSystemLabel, +} from '#site/util/download'; +import { getNodeDownloadUrl } from '#site/util/url'; + +import type { FC } from 'react'; + +// Retrieves the pure extension piece from the input string +const getExtension = (input: string) => String(input.split('.').slice(-1)); + +const PrebuiltDownloadButtons: FC = () => { + const t = useTranslations(); + const { release, os, platform } = use(ReleaseContext); + + const installerUrl = platform + ? getNodeDownloadUrl({ + versionWithPrefix: release.versionWithPrefix, + os, + platform, + kind: 'installer', + }) + : ''; + + const binaryUrl = platform + ? getNodeDownloadUrl({ + versionWithPrefix: release.versionWithPrefix, + os, + platform, + kind: 'binary', + }) + : ''; + + return ( + <div className="my-4 flex flex-col gap-2 sm:flex-row"> + <Skeleton + loading={os === 'LOADING' || platform === ''} + hide={OS_NOT_SUPPORTING_INSTALLERS.includes(os)} + > + <Button + href={installerUrl} + size="small" + className="w-full min-w-56 sm:w-auto" + > + <CloudArrowDownIcon /> + + {t('layouts.download.buttons.installer', { + os: OperatingSystemLabel[os], + extension: getExtension(installerUrl), + })} + </Button> + </Skeleton> + + <Skeleton loading={os === 'LOADING' || platform === ''}> + <Button + href={binaryUrl} + size="small" + className="w-full min-w-56 sm:w-auto" + > + <CloudArrowDownIcon /> + + {t('layouts.download.buttons.binary', { + extension: getExtension(binaryUrl), + })} + </Button> + </Skeleton> + </div> + ); +}; + +export default PrebuiltDownloadButtons; diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 9b6b5d8628eb4..829131d7ceed5 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -1,49 +1,187 @@ 'use client'; +import { highlightToHtml } from '@node-core/rehype-shiki/minimal'; +import AlertBox from '@node-core/ui-components/Common/AlertBox'; +import Skeleton from '@node-core/ui-components/Common/Skeleton'; import { useTranslations } from 'next-intl'; -import { useContext, useEffect, useState } from 'react'; +import { use, useMemo } from 'react'; + +import CodeBox from '#site/components/Common/CodeBox'; +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import Link from '#site/components/Link'; +import WithReleaseAlertBox from '#site/components/withReleaseAlertBox'; +import { + ReleaseContext, + ReleasesContext, +} from '#site/providers/releaseProvider'; +import { INSTALL_METHODS } from '#site/util/download'; +import createInterpreter from '#site/util/interpreter'; + +import type { DownloadSnippet } from '#site/types/download'; +import type { ReleaseContextType } from '#site/types/release'; import type { FC } from 'react'; -import CodeBox from '@/components/Common/CodeBox'; -import { ReleaseContext } from '@/providers/releaseProvider'; -import { getShiki, highlightToHtml } from '@/util/getHighlighter'; -import { getNodeDownloadSnippet } from '@/util/getNodeDownloadSnippet'; +// Creates a minimal JavaScript interpreter for parsing the JavaScript code from the snippets +// Note: that the code runs inside a sandboxed environment and cannot interact with any code outside of the sandbox +// It also does not have access to any Global or Window objects, nor it can execute code on the end-user's browser +// It also only allows a return statement for a string and it forces the return value to also be a string and only be used +// by Shiki to render the highlighted syntax. Hence XSS attacks or JavaScript injections are not possible. +const interpreter = createInterpreter({}, 'script'); -// We cannot do top-level awaits on utilities or code that is imported by client-only components -// hence we only declare a Promise and let it be fulfilled by the first call to the function -const memoizedShiki = getShiki(); +/** + * Parses a snippet string using the interpreter with the given release context + */ +const parseSnippet = (snippet: string, releaseContext: ReleaseContextType) => { + interpreter.import({ props: releaseContext }); + interpreter.run(`exports.content = \`${snippet}\``); + return interpreter.exports.content; +}; -const ReleaseCodeBox: FC = () => { - const { platform, os, release } = useContext(ReleaseContext); +/** + * Custom hook to handle snippet processing logic + */ +const useSnippetProcessor = ( + snippets: Array<DownloadSnippet>, + context: ReleaseContextType +) => { + return useMemo(() => { + // Find relevant snippets + const installMethodSnippet = snippets.find( + ({ name }) => name === context.installMethod.toLowerCase() + ); + + const packageManagerSnippet = snippets.find( + ({ name }) => name === context.packageManager.toLowerCase() + ); + + // Only process if both snippets are available + if (!installMethodSnippet || !packageManagerSnippet) { + return ''; + } + + const verifyNodeSnippet = snippets.find(({ name }) => name === 'node'); + + const installCorepackSnippet = + context.packageManager !== 'NPM' && + // Corepack is no longer distributed with Node.js v25 + context.release.major >= 25 && + snippets.find(({ name }) => name === 'corepack'); + + // Combine and parse snippets + const parsedContent = parseSnippet( + [ + installMethodSnippet, + verifyNodeSnippet, + installCorepackSnippet, + packageManagerSnippet, + ] + .filter(Boolean) + .map(snippet => (snippet as DownloadSnippet).content) + .join('\n'), + context + ); + + // Convert to HTML using Shiki's highlighter + // This is faster than JSX rendering as it avoids React runtime overhead + return highlightToHtml( + parsedContent, + context.os === 'WIN' ? 'ps1' : 'bash' + ); + }, [snippets, context]); +}; + +/** + * Custom hook to get current platform information + */ +const usePlatformInfo = (installMethod: string) => { + return useMemo(() => { + const platform = INSTALL_METHODS.find( + ({ value }) => value === installMethod + ); - const [code, setCode] = useState(''); + // Provide defaults for destructuring + return { + label: platform?.label || '', + url: platform?.url || '', + info: platform?.info || 'layouts.download.codeBox.platformInfo.default', + recommended: platform?.recommended || false, + exists: !!platform, + }; + }, [installMethod]); +}; + +/** + * ReleaseCodeBox component displays installation instructions based on platform and context + */ +const ReleaseCodeBox: FC = () => { + const { snippets } = use(ReleasesContext); + const context = use(ReleaseContext); const t = useTranslations(); - useEffect(() => { - const updatedCode = getNodeDownloadSnippet(release, os, t)[platform]; - // Docker and NVM support downloading tags/versions by their full release number - // but usually we should recommend users to download "major" versions - // since our Download Buttons get the latest minor of a major, it does make sense - // to request installation of a major via a package manager - memoizedShiki - .then(shiki => highlightToHtml(shiki)(updatedCode, 'bash')) - .then(setCode); - // Only react when the specific release number changed - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [release.versionWithPrefix, os, platform]); - - const codeLanguage = os === 'WIN' ? 'PowerShell' : 'Bash'; + // Process platform information + const platformInfo = usePlatformInfo(context.installMethod); + + // Process snippets + const parsedSnippets = useSnippetProcessor(snippets, context); + + // UI state calculations + const displayLanguage = context.os === 'WIN' ? 'PowerShell' : 'Bash'; + const isLoading = context.os === 'LOADING' || context.installMethod === ''; return ( - <div className="mb-2 mt-6 flex flex-col gap-2"> - <CodeBox language={codeLanguage} className="min-h-[15.5rem]"> - <code dangerouslySetInnerHTML={{ __html: code }} /> - </CodeBox> + <div className="mt-4 mb-6 flex flex-col gap-2"> + {/* NoScript warning */} + <noscript> + <AlertBox + title={t('components.common.alertBox.warning')} + level="warning" + size="small" + > + {t.rich('layouts.download.codeBox.noScriptDetected', { + link: text => ( + <Link href="/download/archive/current"> + <b>{text}</b> + </Link> + ), + })} + </AlertBox> + </noscript> + + {/* Release status alert */} + <WithReleaseAlertBox status={context.release.status} /> + + {/* Community platform notice */} + {platformInfo.exists && !platformInfo.recommended && ( + <AlertBox + title={t('components.common.alertBox.info')} + level="info" + size="small" + > + {t('layouts.download.codeBox.communityPlatformInfo')} + </AlertBox> + )} + + {/* Code display with skeleton loading */} + <Skeleton loading={isLoading}> + <CodeBox language={displayLanguage} className="min-h-[19rem]"> + {/* eslint-disable-next-line @eslint-react/dom-no-dangerously-set-innerhtml */} + <code dangerouslySetInnerHTML={{ __html: parsedSnippets }} /> + </CodeBox> + </Skeleton> + {/* Platform info footer */} <span className="text-center text-xs text-neutral-800 dark:text-neutral-200"> - {t('layouts.download.codeBox.communityWarning')} - <br /> - <b>{t('layouts.download.codeBox.communityWarningReport')}</b> + <Skeleton loading={isLoading} hide={!platformInfo.exists}> + {t(platformInfo.info, { platform: platformInfo.label })}{' '} + {t.rich('layouts.download.codeBox.externalSupportInfo', { + platform: platformInfo.label, + link: text => ( + <LinkWithArrow href={platformInfo.url}> + <b>{text}</b> + </LinkWithArrow> + ), + })} + </Skeleton> </span> </div> ); diff --git a/apps/site/components/Downloads/Release/ReleaseStatus.tsx b/apps/site/components/Downloads/Release/ReleaseStatus.tsx deleted file mode 100644 index 40a6360964310..0000000000000 --- a/apps/site/components/Downloads/Release/ReleaseStatus.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import { useContext } from 'react'; -import type { FC } from 'react'; - -import { ReleaseContext } from '@/providers/releaseProvider'; - -const ReleaseStatus: FC = () => { - const { - release: { status }, - } = useContext(ReleaseContext); - - return <>{status}</>; -}; - -export default ReleaseStatus; diff --git a/apps/site/components/Downloads/Release/ReleaseVersion.tsx b/apps/site/components/Downloads/Release/ReleaseVersion.tsx deleted file mode 100644 index c485af2c607d2..0000000000000 --- a/apps/site/components/Downloads/Release/ReleaseVersion.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import { useContext } from 'react'; -import type { FC } from 'react'; - -import { ReleaseContext } from '@/providers/releaseProvider'; - -const ReleaseVersion: FC = () => { - const { - release: { version }, - } = useContext(ReleaseContext); - - return <>{version}</>; -}; - -export default ReleaseVersion; diff --git a/apps/site/components/Downloads/Release/SourceButton.tsx b/apps/site/components/Downloads/Release/SourceButton.tsx deleted file mode 100644 index ef500376281c4..0000000000000 --- a/apps/site/components/Downloads/Release/SourceButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { CloudArrowDownIcon } from '@heroicons/react/24/outline'; -import { useTranslations } from 'next-intl'; -import { useContext } from 'react'; -import type { FC } from 'react'; - -import Button from '@/components/Common/Button'; -import { ReleaseContext } from '@/providers/releaseProvider'; - -const SourceButton: FC = () => { - const t = useTranslations(); - const { release } = useContext(ReleaseContext); - - const version = release.versionWithPrefix; - const url = `https://nodejs.org/dist/${version}/node-${version}.tar.gz`; - - return ( - <div className="mb-2 mt-6 flex items-center gap-2"> - <Button href={url} disabled={!version}> - <CloudArrowDownIcon /> - {t('layouts.download.buttons.source', { - version: version, - })} - </Button> - </div> - ); -}; - -export default SourceButton; diff --git a/apps/site/components/Downloads/Release/VerifyingBinariesLink.tsx b/apps/site/components/Downloads/Release/VerifyingBinariesLink.tsx deleted file mode 100644 index ff6fc2e1a4dae..0000000000000 --- a/apps/site/components/Downloads/Release/VerifyingBinariesLink.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { FC, PropsWithChildren } from 'react'; - -import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow'; - -const VerifyingBinariesLink: FC<PropsWithChildren> = ({ children }) => ( - <LinkWithArrow href="https://github.com/nodejs/node#verifying-binaries"> - {children} - </LinkWithArrow> -); - -export default VerifyingBinariesLink; diff --git a/apps/site/components/Downloads/Release/VersionDropdown.tsx b/apps/site/components/Downloads/Release/VersionDropdown.tsx index 6701792817e12..c13c7a7488f2f 100644 --- a/apps/site/components/Downloads/Release/VersionDropdown.tsx +++ b/apps/site/components/Downloads/Release/VersionDropdown.tsx @@ -1,38 +1,56 @@ 'use client'; -import { useTranslations } from 'next-intl'; +import WithNoScriptSelect from '@node-core/ui-components/Common/Select/NoScriptSelect'; +import { useLocale, useTranslations } from 'next-intl'; +import { use } from 'react'; + +import { redirect, usePathname } from '#site/navigation'; +import { STATUS_KIND_MAP } from '#site/next.constants.mjs'; +import { + ReleaseContext, + ReleasesContext, +} from '#site/providers/releaseProvider'; + import type { FC } from 'react'; -import { useContext } from 'react'; -import Select from '@/components/Common/Select'; -import { ReleaseContext } from '@/providers/releaseProvider'; +const VersionDropdown: FC = () => { + const { releases } = use(ReleasesContext); + const { release, setVersion } = use(ReleaseContext); + const t = useTranslations(); + const locale = useLocale(); + const pathname = usePathname(); -const getDropDownStatus = (version: string, status: string) => { - if (status === 'LTS') { - return `${version} (LTS)`; - } + // Allows us to keep the route semantically correct to what the user should expect + // from the /current and non /current routes. + const setVersionOrNavigate = (version: string) => { + const release = releases.find( + ({ versionWithPrefix }) => versionWithPrefix === version + ); - if (status === 'Current') { - return `${version} (Current)`; - } + if (release?.status === 'LTS' && pathname.includes('current')) { + redirect({ href: '/download', locale }); + return; + } - return version; -}; + if (release?.status === 'Current' && !pathname.includes('current')) { + redirect({ href: '/download/current', locale }); + return; + } -const VersionDropdown: FC = () => { - const { releases, release, setVersion } = useContext(ReleaseContext); - const t = useTranslations(); + setVersion(version); + }; return ( - <Select + <WithNoScriptSelect ariaLabel={t('layouts.download.dropdown.version')} values={releases.map(({ status, versionWithPrefix }) => ({ value: versionWithPrefix, - label: getDropDownStatus(versionWithPrefix, status), + label: versionWithPrefix, + badge: { label: status, kind: STATUS_KIND_MAP[status] }, }))} defaultValue={release.versionWithPrefix} - onChange={setVersion} - className="w-40" + onChange={setVersionOrNavigate} + className="min-w-36" inline={true} /> ); diff --git a/apps/site/components/EOL/EOLAlert.tsx b/apps/site/components/EOL/EOLAlert.tsx new file mode 100644 index 0000000000000..f6718e3884bf4 --- /dev/null +++ b/apps/site/components/EOL/EOLAlert.tsx @@ -0,0 +1,17 @@ +import AlertBox from '@node-core/ui-components/Common/AlertBox'; +import { useTranslations } from 'next-intl'; + +import Link from '#site/components/Link'; + +const EOLAlert = () => { + const t = useTranslations(); + return ( + <AlertBox level="warning"> + {t.rich('components.eolAlert.message', { + link: text => <Link href="/about/eol">{text}</Link>, + })} + </AlertBox> + ); +}; + +export default EOLAlert; diff --git a/apps/site/components/EOL/EOLModal.tsx b/apps/site/components/EOL/EOLModal.tsx new file mode 100644 index 0000000000000..dbcb5ea517dc4 --- /dev/null +++ b/apps/site/components/EOL/EOLModal.tsx @@ -0,0 +1,67 @@ +import { Modal, Title, Content } from '@node-core/ui-components/Common/Modal'; +import { useTranslations } from 'next-intl'; +import { useEffect } from 'react'; + +import KnownSeveritySection from '#site/components/EOL/KnownSeveritySection'; +import UnknownSeveritySection from '#site/components/EOL/UnknownSeveritySection'; +import { SEVERITY_ORDER } from '#site/next.constants.mjs'; + +import type { NodeRelease } from '#site/types/releases'; +import type { Vulnerability } from '#site/types/vulnerabilities'; +import type { FC, ComponentProps } from 'react'; + +type EOLModalProps = ComponentProps<typeof Modal> & { + release: NodeRelease; + vulnerabilities: Array<Vulnerability>; +}; + +const EOLModal: FC<EOLModalProps> = ({ + release: { codename, major: version }, + vulnerabilities, + ...props +}) => { + const t = useTranslations(); + + const modalHeading = codename + ? t('components.eolModal.title', { version, codename }) + : t('components.eolModal.titleWithoutCodename', { version }); + + useEffect( + () => { + vulnerabilities.sort( + (a, b) => + SEVERITY_ORDER.indexOf(a.severity) - + SEVERITY_ORDER.indexOf(b.severity) + ); + }, + // Only change when the vulnerabilities change + // eslint-disable-next-line @eslint-react/exhaustive-deps + [vulnerabilities.length] + ); + + return ( + <Modal {...props}> + <Title>{modalHeading} + + {vulnerabilities.length > 0 && ( +

+ {t('components.eolModal.vulnerabilitiesMessage', { + count: vulnerabilities.length, + })} +

+ )} + + + + + {!vulnerabilities.length && ( +

+ {t('components.eolModal.noVulnerabilitiesMessage')} +

+ )} +
+ + ); +}; + +export default EOLModal; diff --git a/apps/site/components/EOL/EOLReleaseTable/TableBody.tsx b/apps/site/components/EOL/EOLReleaseTable/TableBody.tsx new file mode 100644 index 0000000000000..85c9bc5dbd91b --- /dev/null +++ b/apps/site/components/EOL/EOLReleaseTable/TableBody.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Fragment, useState } from 'react'; + +import FormattedTime from '#site/components/Common/FormattedTime'; +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import EOLModal from '#site/components/EOL/EOLModal'; +import VulnerabilityChips from '#site/components/EOL/VulnerabilityChips'; + +import type { GroupedVulnerabilities, NodeRelease } from '#site/types'; +import type { FC } from 'react'; + +type EOLReleaseTableBodyProps = { + eolReleases: Array; + vulnerabilities: GroupedVulnerabilities; +}; + +const EOLReleaseTableBody: FC = ({ + eolReleases, + vulnerabilities, +}) => { + const t = useTranslations(); + + const [currentModal, setCurrentModal] = useState(); + + return ( + + {eolReleases.map(release => ( + + + + v{release.major} {release.codename ? `(${release.codename})` : ''} + + + + + + + + + + + + setCurrentModal(release.version)} + > + {t('components.downloadReleasesTable.details')} + + + + + open || setCurrentModal(undefined)} + /> + + ))} + + ); +}; + +export default EOLReleaseTableBody; diff --git a/apps/site/components/EOL/EOLReleaseTable/index.module.css b/apps/site/components/EOL/EOLReleaseTable/index.module.css new file mode 100644 index 0000000000000..a8ae64d8ac8d6 --- /dev/null +++ b/apps/site/components/EOL/EOLReleaseTable/index.module.css @@ -0,0 +1,9 @@ +@reference "../../../styles/index.css"; + +.eolTableWrapper { + @apply contents; +} + +.eolTableWrapper:has(input[type='checkbox']:checked) tr[data-lts='false'] { + @apply hidden; +} diff --git a/apps/site/components/EOL/EOLReleaseTable/index.tsx b/apps/site/components/EOL/EOLReleaseTable/index.tsx new file mode 100644 index 0000000000000..bf00f75bfb6e2 --- /dev/null +++ b/apps/site/components/EOL/EOLReleaseTable/index.tsx @@ -0,0 +1,46 @@ +import Switch from '@node-core/ui-components/Common/Switch'; +import { getTranslations } from 'next-intl/server'; + +import provideReleaseData from '#site/next-data/providers/releaseData'; +import provideVulnerabilities from '#site/next-data/providers/vulnerabilities'; + +import type { FC } from 'react'; + +import EOLReleaseTableBody from './TableBody'; + +import styles from './index.module.css'; + +const EOLReleaseTable: FC = async () => { + const releaseData = await provideReleaseData(); + const vulnerabilities = await provideVulnerabilities(); + + const eolReleases = releaseData.filter(release => release.status === 'EOL'); + + const t = await getTranslations(); + + return ( +
+ + + + + + + + + + + + +
+ {t('components.eolTable.version')} ( + {t('components.eolTable.codename')}) + {t('components.eolTable.lastUpdated')}{t('components.eolTable.vulnerabilities')}{t('components.eolTable.details')}
+
+ ); +}; + +export default EOLReleaseTable; diff --git a/apps/site/components/EOL/KnownSeveritySection.tsx b/apps/site/components/EOL/KnownSeveritySection.tsx new file mode 100644 index 0000000000000..4a12a383e7400 --- /dev/null +++ b/apps/site/components/EOL/KnownSeveritySection.tsx @@ -0,0 +1,24 @@ +import VulnerabilitiesTable from '#site/components/EOL/VulnerabilitiesTable'; + +import type { Vulnerability } from '#site/types/vulnerabilities'; +import type { FC } from 'react'; + +type KnownSeveritySectionProps = { + vulnerabilities: Array; +}; + +const KnownSeveritySection: FC = ({ + vulnerabilities, +}) => { + const knownVulnerabilities = vulnerabilities.filter( + v => v.severity !== 'unknown' + ); + + if (!knownVulnerabilities.length) { + return null; + } + + return ; +}; + +export default KnownSeveritySection; diff --git a/apps/site/components/EOL/UnknownSeveritySection.tsx b/apps/site/components/EOL/UnknownSeveritySection.tsx new file mode 100644 index 0000000000000..dada3aaefe6b1 --- /dev/null +++ b/apps/site/components/EOL/UnknownSeveritySection.tsx @@ -0,0 +1,41 @@ +import { useTranslations } from 'next-intl'; + +import VulnerabilitiesTable from '#site/components/EOL/VulnerabilitiesTable'; + +import type { Vulnerability } from '#site/types/vulnerabilities'; +import type { FC } from 'react'; + +type UnknownSeveritySectionProps = { + vulnerabilities: Array; +}; + +const UnknownSeveritySection: FC = ({ + vulnerabilities, +}) => { + const t = useTranslations(); + + const unknownVulnerabilities = vulnerabilities.filter( + v => v.severity === 'unknown' + ); + + if (!unknownVulnerabilities.length) { + return null; + } + + return ( +
+ + {t('components.eolModal.showUnknownSeverities')} ( + {unknownVulnerabilities.length}) + +
+ +
+
+ ); +}; + +export default UnknownSeveritySection; diff --git a/apps/site/components/EOL/VulnerabilitiesTable.tsx b/apps/site/components/EOL/VulnerabilitiesTable.tsx new file mode 100644 index 0000000000000..818c9364ecf63 --- /dev/null +++ b/apps/site/components/EOL/VulnerabilitiesTable.tsx @@ -0,0 +1,76 @@ +import { useTranslations } from 'next-intl'; + +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import VulnerabilityChip from '#site/components/EOL/VulnerabilityChips/VulnerabilityChip'; + +import type { Vulnerability } from '#site/types/vulnerabilities'; +import type { FC } from 'react'; + +const VulnerabilitiesTable: FC<{ + vulnerabilities: Array; + maxWidth?: string; +}> = ({ vulnerabilities, maxWidth = 'md:max-w-2xs' }) => { + const t = useTranslations(); + + if (!vulnerabilities.length) { + return null; + } + + return ( + + + + + + + + + + + {vulnerabilities.map(vulnerability => ( + + + + + + + ))} + +
{t('components.eolModal.table.cves')}{t('components.eolModal.table.severity')}{t('components.eolModal.table.overview')}{t('components.eolModal.table.details')}
+ {vulnerability.cve.map(cveId => ( +
+ + {cveId} + +
+ ))} + + {vulnerability.cve.length > 0 || '-'} +
+ + + {vulnerability.description || vulnerability.overview || '-'} + + {vulnerability.url && ( + + {t('components.eolModal.blogLinkText')} + + )} + + {!!vulnerability.url || '-'} +
+ ); +}; + +export default VulnerabilitiesTable; diff --git a/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.module.css b/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.module.css new file mode 100644 index 0000000000000..0dc0a5ebc7337 --- /dev/null +++ b/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.module.css @@ -0,0 +1,8 @@ +@reference "../../../../styles/index.css"; + +.chipCount { + @apply mr-1 + rounded-sm + bg-gray-800/20 + px-1.5; +} diff --git a/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.tsx b/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.tsx new file mode 100644 index 0000000000000..b0673dfe508ca --- /dev/null +++ b/apps/site/components/EOL/VulnerabilityChips/VulnerabilityChip/index.tsx @@ -0,0 +1,31 @@ +import Badge from '@node-core/ui-components/Common/Badge'; +import { useTranslations } from 'next-intl'; + +import { SEVERITY_KIND_MAP } from '#site/next.constants.mjs'; + +import type { Severity } from '#site/types'; +import type { BadgeKind } from '@node-core/ui-components/Common/Badge'; +import type { FC } from 'react'; + +import styles from './index.module.css'; + +type VulnerabilityChipProps = { + severity: Severity; + count?: number; +}; + +const VulnerabilityChip: FC = ({ + severity, + count = 0, +}) => { + const t = useTranslations(); + + return ( + + {count > 0 ? {count} : null} + {t(`components.eolChip.severity.${severity}`)} + + ); +}; + +export default VulnerabilityChip; diff --git a/apps/site/components/EOL/VulnerabilityChips/index.tsx b/apps/site/components/EOL/VulnerabilityChips/index.tsx new file mode 100644 index 0000000000000..4a6753e774924 --- /dev/null +++ b/apps/site/components/EOL/VulnerabilityChips/index.tsx @@ -0,0 +1,40 @@ +import { SEVERITY_ORDER } from '#site/next.constants.mjs'; + +import type { Severity, Vulnerability } from '#site/types'; +import type { FC } from 'react'; + +import VulnerabilityChip from './VulnerabilityChip'; + +type VulnerabilityChipsProps = { + vulnerabilities: Array; +}; + +const VulnerabilityChips: FC = ({ + vulnerabilities, +}) => { + // Group vulnerabilities by severity + const groupedBySeverity = vulnerabilities.reduce>( + (acc, vuln) => { + const severity = vuln.severity.toLowerCase(); + acc[severity] = (acc[severity] || 0) + 1; + return acc; + }, + {} + ); + + return ( +
+ {SEVERITY_ORDER.filter(severity => groupedBySeverity[severity] > 0).map( + severity => ( + + ) + )} +
+ ); +}; + +export default VulnerabilityChips; diff --git a/apps/site/components/Icons/Logos/JsIconGreen.tsx b/apps/site/components/Icons/Logos/JsIconGreen.tsx deleted file mode 100644 index dc835cb1d2af7..0000000000000 --- a/apps/site/components/Icons/Logos/JsIconGreen.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const JsIconGreen: FC> = props => ( - - - - - - - - - - - -); - -export default JsIconGreen; diff --git a/apps/site/components/Icons/Logos/JsIconWhite.tsx b/apps/site/components/Icons/Logos/JsIconWhite.tsx deleted file mode 100644 index 1063212ba130a..0000000000000 --- a/apps/site/components/Icons/Logos/JsIconWhite.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { FC } from 'react'; - -import type { TailwindSVG } from '@/types/og'; - -const JsIconWhite: FC = props => ( - - - - - - - - - - - -); - -export default JsIconWhite; diff --git a/apps/site/components/Icons/Logos/Nodejs.tsx b/apps/site/components/Icons/Logos/Nodejs.tsx deleted file mode 100644 index d614cb05e223e..0000000000000 --- a/apps/site/components/Icons/Logos/Nodejs.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import classNames from 'classnames'; -import type { FC, SVGProps } from 'react'; - -import type { LogoVariant } from '@/types'; - -type NodeJsLogoProps = SVGProps & { - variant?: LogoVariant; -}; - -const Nodejs: FC = ({ - className, - variant = 'default', - ...props -}) => ( - - - - - - - - - - - - - - {variant === 'pride' ? ( - <> - - - - - - - - - - - - - ) : ( - <> - - - - - )} - - {variant === 'pride' ? ( - - - - - - ) : ( - - - - - - - - - - - - - - - - - - - - - - )} - -); - -export default Nodejs; diff --git a/apps/site/components/Icons/Logos/NodejsStackedBlack.tsx b/apps/site/components/Icons/Logos/NodejsStackedBlack.tsx deleted file mode 100644 index 1401ff613303d..0000000000000 --- a/apps/site/components/Icons/Logos/NodejsStackedBlack.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const NodejsStackedBlack: FC> = props => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export default NodejsStackedBlack; diff --git a/apps/site/components/Icons/Logos/NodejsStackedDark.tsx b/apps/site/components/Icons/Logos/NodejsStackedDark.tsx deleted file mode 100644 index b4082253c667b..0000000000000 --- a/apps/site/components/Icons/Logos/NodejsStackedDark.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const NodejsStackedDark: FC> = props => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export default NodejsStackedDark; diff --git a/apps/site/components/Icons/Logos/NodejsStackedLight.tsx b/apps/site/components/Icons/Logos/NodejsStackedLight.tsx deleted file mode 100644 index 6e47796744849..0000000000000 --- a/apps/site/components/Icons/Logos/NodejsStackedLight.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const NodejsStackedLight: FC> = props => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export default NodejsStackedLight; diff --git a/apps/site/components/Icons/Logos/NodejsStackedWhite.tsx b/apps/site/components/Icons/Logos/NodejsStackedWhite.tsx deleted file mode 100644 index 19d83835bb149..0000000000000 --- a/apps/site/components/Icons/Logos/NodejsStackedWhite.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const NodejsStackedWhite: FC> = props => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export default NodejsStackedWhite; diff --git a/apps/site/components/Icons/Platform/FNM.tsx b/apps/site/components/Icons/Platform/FNM.tsx deleted file mode 100644 index 90407358527b3..0000000000000 --- a/apps/site/components/Icons/Platform/FNM.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -// @todo: replace with original vector (https://github.com/Schniz/fnm/issues/798#issuecomment-2068220441) -// this is a rough tracing of the available raster image -const FNM: FC> = props => ( - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export default FNM; diff --git a/apps/site/components/Icons/Platform/Generic.tsx b/apps/site/components/Icons/Platform/Generic.tsx deleted file mode 100644 index e2ae454f062a1..0000000000000 --- a/apps/site/components/Icons/Platform/Generic.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const Generic: FC> = props => ( - - - -); - -export default Generic; diff --git a/apps/site/components/Icons/Social/Twitter.tsx b/apps/site/components/Icons/Social/Twitter.tsx deleted file mode 100644 index 6567d311d7c1d..0000000000000 --- a/apps/site/components/Icons/Social/Twitter.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { FC, SVGProps } from 'react'; - -const Twitter: FC> = props => ( - - - -); - -export default Twitter; diff --git a/apps/site/components/Link.tsx b/apps/site/components/Link.tsx index 8253b32fad5b2..d466df09e1962 100644 --- a/apps/site/components/Link.tsx +++ b/apps/site/components/Link.tsx @@ -1,24 +1,21 @@ -import type { FC, ComponentProps } from 'react'; +import { Link as LocalizedLink } from '#site/navigation.mjs'; -import { Link as LocalizedLink } from '@/navigation.mjs'; +import type { FC, AnchorHTMLAttributes, ComponentProps } from 'react'; -type LinkProps = Omit, 'href'> & { - href?: string; -}; +export type LinkProps = + | AnchorHTMLAttributes + | ComponentProps; -const Link: FC = ({ children, href, ...props }) => { - if (!href || href.toString().startsWith('http')) { - return ( -
- {children} - - ); +const Link: FC = ({ href, ...props }) => { + if (!href || /^https?:/.test(href as string)) { + return ; } return ( - - {children} - + , 'href'>)} + /> ); }; diff --git a/apps/site/components/MDX/Calendar/Event/index.module.css b/apps/site/components/MDX/Calendar/Event/index.module.css index f36d7d8f92aba..f192532d77c24 100644 --- a/apps/site/components/MDX/Calendar/Event/index.module.css +++ b/apps/site/components/MDX/Calendar/Event/index.module.css @@ -1,3 +1,5 @@ +@reference "../../../../styles/index.css"; + .event { @apply flex w-fit diff --git a/apps/site/components/MDX/Calendar/Event/index.stories.tsx b/apps/site/components/MDX/Calendar/Event/index.stories.tsx deleted file mode 100644 index d35c692533176..0000000000000 --- a/apps/site/components/MDX/Calendar/Event/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Event from '@/components/MDX/Calendar/Event'; - -type Story = StoryObj; -type Meta = MetaObj; - -export const Default: Story = { - args: { - start: { date: '2024-02-19T12:30:00.000Z' }, - end: { date: '2024-02-19T16:00:00.000Z' }, - summary: 'Example Event', - location: 'Event Location', - description: 'This is an example event description.', - }, -}; - -export default { component: Event } as Meta; diff --git a/apps/site/components/MDX/Calendar/Event/index.tsx b/apps/site/components/MDX/Calendar/Event/index.tsx index a83a62df801d8..63f63441e49ff 100644 --- a/apps/site/components/MDX/Calendar/Event/index.tsx +++ b/apps/site/components/MDX/Calendar/Event/index.tsx @@ -1,9 +1,9 @@ -import type { FC } from 'react'; +import FormattedTime from '#site/components/Common/FormattedTime'; +import Link from '#site/components/Link'; +import { getZoomLink, isZoned } from '#site/components/MDX/Calendar/utils'; -import FormattedTime from '@/components/Common/FormattedTime'; -import Link from '@/components/Link'; -import { getZoomLink, isZoned } from '@/components/MDX/Calendar/utils'; -import type { CalendarEvent } from '@/types'; +import type { CalendarEvent } from '#site/types'; +import type { FC } from 'react'; import styles from './index.module.css'; diff --git a/apps/site/components/MDX/Calendar/UpcomingMeetings.tsx b/apps/site/components/MDX/Calendar/UpcomingMeetings.tsx index cac02b5d71fc8..7a674286db712 100644 --- a/apps/site/components/MDX/Calendar/UpcomingMeetings.tsx +++ b/apps/site/components/MDX/Calendar/UpcomingMeetings.tsx @@ -1,11 +1,11 @@ -import type { FC } from 'react'; +import FormattedTime from '#site/components/Common/FormattedTime'; +import Event from '#site/components/MDX/Calendar/Event'; +import { getZoomLink, isZoned } from '#site/components/MDX/Calendar/utils'; +import { CALENDAR_NODEJS_ID } from '#site/next.calendar.constants.mjs'; +import { getCalendarEvents } from '#site/next.calendar.mjs'; -import FormattedTime from '@/components/Common/FormattedTime'; -import Event from '@/components/MDX/Calendar/Event'; -import { getZoomLink, isZoned } from '@/components/MDX/Calendar/utils'; -import { CALENDAR_NODEJS_ID } from '@/next.calendar.constants.mjs'; -import { getCalendarEvents } from '@/next.calendar.mjs'; -import type { CalendarEvent } from '@/types'; +import type { CalendarEvent } from '#site/types'; +import type { FC } from 'react'; import styles from './calendar.module.css'; diff --git a/apps/site/components/MDX/Calendar/calendar.module.css b/apps/site/components/MDX/Calendar/calendar.module.css index 52af750828ddf..ef0999b2b9d58 100644 --- a/apps/site/components/MDX/Calendar/calendar.module.css +++ b/apps/site/components/MDX/Calendar/calendar.module.css @@ -1,3 +1,5 @@ +@reference "../../../styles/index.css"; + .events { @apply flex flex-col diff --git a/apps/site/components/MDX/Calendar/utils.ts b/apps/site/components/MDX/Calendar/utils.ts index 7b0f632577172..0c051431d969d 100644 --- a/apps/site/components/MDX/Calendar/utils.ts +++ b/apps/site/components/MDX/Calendar/utils.ts @@ -1,4 +1,4 @@ -import type { CalendarEvent, ZonedCalendarTime } from '@/types'; +import type { CalendarEvent, ZonedCalendarTime } from '#site/types'; export const isZoned = (d: object): d is ZonedCalendarTime => 'dateTime' in d && 'timeZone' in d; diff --git a/apps/site/components/MDX/CodeBox/index.stories.tsx b/apps/site/components/MDX/CodeBox/index.stories.tsx deleted file mode 100644 index a460d95f8da7b..0000000000000 --- a/apps/site/components/MDX/CodeBox/index.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import { VFile } from 'vfile'; - -import { MDXRenderer } from '@/components/mdxRenderer'; -import { compileMDX } from '@/next.mdx.compiler.mjs'; - -type Props = { children: string }; - -type Story = StoryObj; -type Meta = MetaObj; - -export const Default: Story = { - args: { - children: `\`\`\`javascript -const http = require('http'); - -const hostname = '127.0.0.1'; -const port = 3000; - -const server = http.createServer((req, res) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello World'); -}); - -server.listen(port, hostname, () => { - console.log(\`Server running at http://\${hostname}:\${port}/\`); -}); -\`\`\``, - }, -}; - -export default { - title: 'MDX/CodeBox', - render: (_, { loaded: { Content } }) => Content, - loaders: [ - async ({ args }) => { - const { MDXContent } = await compileMDX(new VFile(args.children), 'mdx'); - - return { Content: }; - }, - ], -} as Meta; diff --git a/apps/site/components/MDX/CodeBox/index.tsx b/apps/site/components/MDX/CodeBox/index.tsx index e1b1aba31ad16..4ea12905881aa 100644 --- a/apps/site/components/MDX/CodeBox/index.tsx +++ b/apps/site/components/MDX/CodeBox/index.tsx @@ -1,23 +1,18 @@ -import type { FC, PropsWithChildren } from 'react'; +import { getLanguageDisplayName } from '@node-core/rehype-shiki'; -import CodeBox from '@/components/Common/CodeBox'; -import { getLanguageDisplayName } from '@/util/getLanguageDisplayName'; +import CodeBox from '#site/components/Common/CodeBox'; -type CodeBoxProps = { className?: string; showCopyButton?: string }; +import type { FC, HTMLAttributes } from 'react'; -const MDXCodeBox: FC> = ({ +const MDXCodeBox: FC> = ({ children: code, className, - showCopyButton, }) => { - const matches = className?.match(/language-(?.*)/); + const matches = className?.match(/language-(?[a-zA-Z]+)/); const language = matches?.groups?.language ?? ''; return ( - + {code} ); diff --git a/apps/site/components/MDX/CodeTabs/index.stories.tsx b/apps/site/components/MDX/CodeTabs/index.stories.tsx deleted file mode 100644 index 45889e0b47888..0000000000000 --- a/apps/site/components/MDX/CodeTabs/index.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; -import { VFile } from 'vfile'; - -import { MDXRenderer } from '@/components/mdxRenderer'; -import { compileMDX } from '@/next.mdx.compiler.mjs'; - -type Props = { children: string }; - -type Story = StoryObj; -type Meta = MetaObj; - -export const Default: Story = { - args: { - children: `\`\`\`mjs -const { createHmac } = await import('node:crypto'); - -const secret = 'abcdefg'; -const hash = createHmac('sha256', secret) - .update('I love cupcakes') - .digest('hex'); - -console.log(hash); -// Prints: -// c0fa1bc00531bd78ef38c628449c5102aeabd49b5dc3a2a516ea6ea959d6658e -\`\`\` - -\`\`\`cjs displayName="CommonJS" showCopyButton="true" -const { createHmac } = require('node:crypto'); - -const secret = 'abcdefg'; -const hash = createHmac('sha256', secret) - .update('I love cupcakes') - .digest('hex'); - -console.log(hash); -// Prints: -// c0fa1bc00531bd78ef38c628449c5102aeabd49b5dc3a2a516ea6ea959d6658e -\`\`\``, - }, -}; - -export default { - title: 'MDX/CodeTabs', - render: (_, { loaded: { Content } }) => Content, - loaders: [ - async ({ args }) => { - const { MDXContent } = await compileMDX(new VFile(args.children), 'mdx'); - - return { Content: }; - }, - ], -} as Meta; diff --git a/apps/site/components/MDX/CodeTabs/index.tsx b/apps/site/components/MDX/CodeTabs/index.tsx deleted file mode 100644 index 76a5fb54409ce..0000000000000 --- a/apps/site/components/MDX/CodeTabs/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import type { ComponentProps, FC, ReactElement } from 'react'; - -import CodeTabs from '@/components/Common/CodeTabs'; - -type MDXCodeTabsProps = Pick< - ComponentProps, - 'linkText' | 'linkUrl' -> & { - children: Array; - languages: string; - displayNames?: string; - defaultTab?: string; -}; - -const MDXCodeTabs: FC = ({ - languages: rawLanguages, - displayNames: rawDisplayNames, - children: codes, - defaultTab = '0', - ...props -}) => { - const languages = rawLanguages.split('|'); - const displayNames = rawDisplayNames?.split('|') ?? []; - - const tabs = languages.map((language, index) => { - const displayName = displayNames[index]; - - return { - key: `${language}-${index}`, - label: displayName?.length ? displayName : language.toUpperCase(), - }; - }); - - return ( - - {languages.map((_, index) => ( - - {codes[index]} - - ))} - - ); -}; - -export default MDXCodeTabs; diff --git a/apps/site/components/MDX/Image/index.tsx b/apps/site/components/MDX/Image/index.tsx index 42634bb9ad24b..b0da9f581e232 100644 --- a/apps/site/components/MDX/Image/index.tsx +++ b/apps/site/components/MDX/Image/index.tsx @@ -1,8 +1,9 @@ -import type { ImageProps } from 'next/image'; import Image from 'next/image'; + +import type { ImageProps } from 'next/image'; import type { FC } from 'react'; -const MDXImage: FC = ({ width, height, alt, ...props }) => { +const MDXImage: FC = ({ width, height, alt, src, ...props }) => { if (!width || !height) { // Since `width` and `height` are not provided in the Markdown image format, // we provide the height and width automatically. @@ -10,6 +11,7 @@ const MDXImage: FC = ({ width, height, alt, ...props }) => { return ( {alt} = ({ width, height, alt, ...props }) => { ); } - return {alt}; + return {alt}; }; export default MDXImage; diff --git a/apps/site/components/MDX/SearchPage/index.module.css b/apps/site/components/MDX/SearchPage/index.module.css deleted file mode 100644 index 777e5af913050..0000000000000 --- a/apps/site/components/MDX/SearchPage/index.module.css +++ /dev/null @@ -1,82 +0,0 @@ -.searchPageContainer { - @apply mx-auto - w-full - px-4 - py-14 - md:max-w-screen-xl; -} - -.searchTermContainer { - @apply relative - flex - w-full - flex-col - justify-start - gap-1 - px-6 - text-left - md:px-0; -} - -.searchResultsColumns { - @apply relative - mt-12 - grid - gap-4 - md:grid-cols-[15%_1fr]; -} - -.facetsColumn { - @apply sticky - top-0 - flex - gap-4 - overflow-x-auto - px-6 - capitalize - md:flex-col - md:px-0; -} - -.facetCount { - @apply ml-2 - text-sm - text-neutral-500 - dark:text-neutral-800; -} - -.resultsColumn { - @apply flex - flex-col - gap-4 - px-2; -} - -.searchResult { - @apply flex - w-full - flex-col - rounded-lg - px-4 - py-2 - hover:bg-neutral-100 - dark:hover:bg-neutral-900; -} - -.searchResultTitle { - @apply text-lg; -} - -.searchResultPageTitle { - @apply text-sm - capitalize - text-neutral-500 - dark:text-neutral-600; -} - -.searchResultSnippet { - @apply my-2 - text-sm - text-neutral-500 - dark:text-neutral-400; -} diff --git a/apps/site/components/MDX/SearchPage/index.tsx b/apps/site/components/MDX/SearchPage/index.tsx deleted file mode 100644 index 1702eb288dc7c..0000000000000 --- a/apps/site/components/MDX/SearchPage/index.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import type { Nullable, Results, Result } from '@orama/orama'; -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { useEffect, useState, type FC } from 'react'; - -import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy'; -import { WithSearchBox } from '@/components/Common/Search/States/WithSearchBox'; -import { pathToBreadcrumbs } from '@/components/Common/Search/utils'; -import Link from '@/components/Link'; -import { useBottomScrollListener } from '@/hooks/react-client'; -import { BASE_URL, DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs'; -import { search as oramaSearch, highlighter } from '@/next.orama.mjs'; -import type { SearchDoc } from '@/types'; - -import styles from './index.module.css'; - -type SearchResults = Nullable>; -type Hit = Result; - -const SearchPage: FC = () => { - const t = useTranslations(); - const searchParams = useSearchParams(); - const [searchResults, setSearchResults] = useState(null); - const [hits, setHits] = useState>([]); - const [offset, setOffset] = useState(0); - - const searchTerm = searchParams?.get('q'); - const searchSection = searchParams?.get('section'); - const [shownSearchBox, setShownSearchbox] = useState(!searchTerm); - - useBottomScrollListener(() => setOffset(offset => offset + 10)); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => search(offset), [offset]); - - useEffect(() => { - setHits([]); - search(0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchSection, searchTerm]); - - const uniqueHits = (newHits: Array) => - newHits.filter( - (obj, index) => newHits.findIndex(item => item.id === obj.id) === index - ); - - const search = (resultsOffset = 0) => { - oramaSearch({ - ...DEFAULT_ORAMA_QUERY_PARAMS, - mode: 'fulltext', - term: searchTerm || '', - limit: 10, - offset: resultsOffset, - ...filterBySection(), - }) - .then(results => { - setSearchResults(results); - setHits(hits => uniqueHits([...hits, ...(results?.hits ?? [])])); - }) - .catch(); - }; - - const facets = { - all: searchResults?.count ?? 0, - ...(searchResults?.facets?.siteSection?.values ?? {}), - }; - - const filterBySection = () => - searchSection && searchSection !== 'all' - ? { where: { siteSection: { eq: searchSection } } } - : {}; - - const getDocumentURL = (siteSection: string, path: string) => { - const isAPIResult = siteSection.toLowerCase() === 'docs'; - const basePath = isAPIResult ? BASE_URL : ''; - return `${basePath}/${path}`; - }; - - return ( -
- {shownSearchBox ? ( - setShownSearchbox(false)} /> - ) : null} -
-

- {t('components.search.searchPage.title', { query: searchTerm })} -

- - -
- -
-
- {Object.keys(facets).map(facetName => ( - - {facetName} - - ({facets[facetName as keyof typeof facets]}) - - - ))} -
- -
- {hits?.map(hit => ( - -
-

- {hit.document.pageSectionTitle} -

- -

- -

- Home {'>'} {pathToBreadcrumbs(hit.document.path).join(' > ')} -
-
- - ))} -
-
-
- ); -}; - -export default SearchPage; diff --git a/apps/site/components/Releases/MinorReleasesTable/index.module.css b/apps/site/components/Releases/MinorReleasesTable/index.module.css new file mode 100644 index 0000000000000..9fc65821e7ff4 --- /dev/null +++ b/apps/site/components/Releases/MinorReleasesTable/index.module.css @@ -0,0 +1,43 @@ +@reference "../../../styles/index.css"; + +.additionalLinks { + @apply flex + h-4 + items-center + gap-2; +} + +.items { + @apply flex + h-9 + gap-2; +} + +.scrollable { + @apply scrollbar-thin + flex + max-h-[29rem] + overflow-y-auto; + + table { + @apply ml:border-t-0 + border-t; + } + + th { + @apply ml:border-t + border-neutral-200 + dark:border-neutral-800; + } +} + +.header { + @apply top-0 + z-10 + border-t + bg-white + text-left + font-semibold + sm:sticky + dark:bg-neutral-950; +} diff --git a/apps/site/components/Releases/MinorReleasesTable/index.tsx b/apps/site/components/Releases/MinorReleasesTable/index.tsx new file mode 100644 index 0000000000000..342d2720f200d --- /dev/null +++ b/apps/site/components/Releases/MinorReleasesTable/index.tsx @@ -0,0 +1,86 @@ +import { CodeBracketSquareIcon } from '@heroicons/react/24/outline'; +import Separator from '@node-core/ui-components/Common/Separator'; +import NpmIcon from '@node-core/ui-components/Icons/PackageManager/Npm'; +import { useTranslations } from 'next-intl'; + +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import Link from '#site/components/Link'; +import ReleaseOverviewItem from '#site/components/Releases/ReleaseOverview/ReleaseOverviewItem'; +import { BASE_CHANGELOG_URL } from '#site/next.constants.mjs'; +import { getNodeApiUrl } from '#site/util/url'; + +import type { MinorVersion } from '#site/types'; +import type { FC } from 'react'; + +import styles from './index.module.css'; + +type MinorReleasesTableProps = { + releases: Array; +}; + +const MinorReleasesTable: FC = ({ releases }) => { + const t = useTranslations(); + + return ( + + + + + + + + + + + + {releases.map(release => ( + + + + + + + + ))} + +
{t('components.minorReleasesTable.version')}{t('components.minorReleasesTable.nApiVersion')}{t('components.minorReleasesTable.npmVersion')}{t('components.minorReleasesTable.v8Version')}{t('components.minorReleasesTable.links')}
+ + v{release.version} + + + {release.modules && ( + + )} + + {release.npm && ( + + )} + + + +
+ + {t('components.minorReleasesTable.actions.docs')} + + + + {t('components.minorReleasesTable.actions.changelog')} + +
+
+ ); +}; + +export default MinorReleasesTable; diff --git a/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx b/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx new file mode 100644 index 0000000000000..f7c047506aadb --- /dev/null +++ b/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx @@ -0,0 +1,80 @@ +'use client'; + +import Badge from '@node-core/ui-components/Common/Badge'; +import { useTranslations } from 'next-intl'; +import { Fragment, useState } from 'react'; + +import FormattedTime from '#site/components/Common/FormattedTime'; +import LinkWithArrow from '#site/components/Common/LinkWithArrow'; +import Link from '#site/components/Link'; +import { STATUS_KIND_MAP } from '#site/next.constants.mjs'; + +import type { NodeRelease } from '#site/types'; +import type { FC } from 'react'; + +import ReleaseModal from '../ReleaseModal'; + +type PreviousReleasesTableBodyProps = { + releaseData: Array; +}; + +const PreviousReleasesTableBody: FC = ({ + releaseData, +}) => { + const t = useTranslations(); + + const [currentModal, setCurrentModal] = useState(); + + return ( + + {releaseData.map(release => ( + + + + + v{release.major} + + + + + {release.codename || '-'} + + + + + + + + + + + + + {release.status} + + + + + setCurrentModal(release.version)} + > + {t('components.downloadReleasesTable.details')} + + + + + open || setCurrentModal(undefined)} + /> + + ))} + + ); +}; + +export default PreviousReleasesTableBody; diff --git a/apps/site/components/Releases/PreviousReleasesTable/index.tsx b/apps/site/components/Releases/PreviousReleasesTable/index.tsx new file mode 100644 index 0000000000000..a018f0114732f --- /dev/null +++ b/apps/site/components/Releases/PreviousReleasesTable/index.tsx @@ -0,0 +1,32 @@ +import { getTranslations } from 'next-intl/server'; + +import provideReleaseData from '#site/next-data/providers/releaseData'; + +import type { FC } from 'react'; + +import PreviousReleasesTableBody from './TableBody'; + +const PreviousReleasesTable: FC = async () => { + const releaseData = await provideReleaseData(); + + const t = await getTranslations(); + + return ( + + + + + + + + + + + + + +
{t('components.downloadReleasesTable.version')}{t('components.downloadReleasesTable.codename')}{t('components.downloadReleasesTable.firstReleased')}{t('components.downloadReleasesTable.lastUpdated')}{t('components.downloadReleasesTable.status')}
+ ); +}; + +export default PreviousReleasesTable; diff --git a/apps/site/components/Releases/ReleaseModal.tsx b/apps/site/components/Releases/ReleaseModal.tsx new file mode 100644 index 0000000000000..25036a777c243 --- /dev/null +++ b/apps/site/components/Releases/ReleaseModal.tsx @@ -0,0 +1,44 @@ +import { Modal, Title, Content } from '@node-core/ui-components/Common/Modal'; +import { useTranslations } from 'next-intl'; + +import MinorReleasesTable from '#site/components/Releases/MinorReleasesTable'; +import ReleaseOverview from '#site/components/Releases/ReleaseOverview'; +import WithReleaseAlertBox from '#site/components/withReleaseAlertBox'; + +import type { NodeRelease } from '#site/types'; +import type { ComponentProps, FC } from 'react'; + +type ReleaseModalProps = ComponentProps & { + release: NodeRelease; +}; + +const ReleaseModal: FC = ({ release, ...props }) => { + const t = useTranslations(); + + const modalHeadingKey = release.codename + ? 'components.releaseModal.title' + : 'components.releaseModal.titleWithoutCodename'; + + const modalHeading = t(modalHeadingKey, { + version: release.major, + codename: release.codename ?? '', + }); + + return ( + + + + {modalHeading} + + + + +
{t('components.releaseModal.minorVersions')}
+ + +
+
+ ); +}; + +export default ReleaseModal; diff --git a/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.module.css b/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.module.css new file mode 100644 index 0000000000000..99e446cdb856d --- /dev/null +++ b/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.module.css @@ -0,0 +1,21 @@ +@reference "../../../../styles/index.css"; + +.item { + @apply flex + items-center + gap-2; + + h1 { + @apply text-sm + font-semibold; + } + + h2 { + @apply text-xs + font-normal; + } + + svg { + @apply size-4; + } +} diff --git a/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.tsx b/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.tsx new file mode 100644 index 0000000000000..36e5d44e092aa --- /dev/null +++ b/apps/site/components/Releases/ReleaseOverview/ReleaseOverviewItem/index.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; + +import type { FC, ReactNode, SVGProps } from 'react'; + +import styles from './index.module.css'; + +type ReleaseOverviewItemProps = { + Icon: FC>; + title: ReactNode; + subtitle?: ReactNode; + className?: string; +}; + +const ReleaseOverviewItem: FC = ({ + Icon, + title, + subtitle, + className, +}) => { + return ( +
+ +
+ {subtitle &&

{subtitle}

} +

{title}

+
+
+ ); +}; + +export default ReleaseOverviewItem; diff --git a/apps/site/components/Releases/ReleaseOverview/index.module.css b/apps/site/components/Releases/ReleaseOverview/index.module.css new file mode 100644 index 0000000000000..7ad4ef58f3f47 --- /dev/null +++ b/apps/site/components/Releases/ReleaseOverview/index.module.css @@ -0,0 +1,18 @@ +@reference "../../../styles/index.css"; + +.root { + @apply rounded + border + border-neutral-200 + p-4 + text-neutral-900 + dark:border-neutral-800 + dark:text-white; + + .container { + @apply grid + grid-cols-2 + gap-4 + lg:grid-cols-3; + } +} diff --git a/apps/site/components/Releases/ReleaseOverview/index.tsx b/apps/site/components/Releases/ReleaseOverview/index.tsx new file mode 100644 index 0000000000000..481314102ead3 --- /dev/null +++ b/apps/site/components/Releases/ReleaseOverview/index.tsx @@ -0,0 +1,73 @@ +import { + CalendarIcon, + ClockIcon, + CodeBracketSquareIcon, + Square3Stack3DIcon, +} from '@heroicons/react/24/outline'; +import NpmIcon from '@node-core/ui-components/Icons/PackageManager/Npm'; +import { useTranslations } from 'next-intl'; + +import FormattedTime from '#site/components/Common/FormattedTime'; + +import type { NodeRelease } from '#site/types'; +import type { FC } from 'react'; + +import ReleaseOverviewItem from './ReleaseOverviewItem'; + +import styles from './index.module.css'; + +type ReleaseOverviewProps = { + release: NodeRelease; +}; + +const ReleaseOverview: FC = ({ release }) => { + const t = useTranslations(); + + return ( +
+
+ } + subtitle={t('components.releaseOverview.firstReleased')} + /> + + } + subtitle={t('components.releaseOverview.lastUpdated')} + /> + + + + {release.modules && ( + + )} + + {release.npm && ( + + )} + + +
+
+ ); +}; + +export default ReleaseOverview; diff --git a/apps/site/components/__design__/node-logos.stories.tsx b/apps/site/components/__design__/node-logos.stories.tsx deleted file mode 100644 index 157a5cd3df709..0000000000000 --- a/apps/site/components/__design__/node-logos.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import JsIconGreen from '@/components/Icons/Logos/JsIconGreen'; -import JsIconWhite from '@/components/Icons/Logos/JsIconWhite'; -import NodejsLogo from '@/components/Icons/Logos/Nodejs'; -import NodejsStackedBlack from '@/components/Icons/Logos/NodejsStackedBlack'; -import NodejsStackedDark from '@/components/Icons/Logos/NodejsStackedDark'; -import NodejsStackedLight from '@/components/Icons/Logos/NodejsStackedLight'; -import NodejsStackedWhite from '@/components/Icons/Logos/NodejsStackedWhite'; - -export const HorizontalLogo: StoryObj = { - render: () => , -}; - -export const PrideLogo: StoryObj = { - render: () => , -}; - -export const StackedLogos: StoryObj = { - render: () => ( -
-
- - - - - - - -
-
- ), -}; - -export const JSSymbols: StoryObj = { - render: () => ( -
- - -
- ), -}; - -export default { title: 'Design System' } as MetaObj; diff --git a/apps/site/components/__design__/platform-logos.stories.tsx b/apps/site/components/__design__/platform-logos.stories.tsx deleted file mode 100644 index e383be1216b01..0000000000000 --- a/apps/site/components/__design__/platform-logos.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import Apple from '@/components/Icons/Platform/Apple'; -import Choco from '@/components/Icons/Platform/Choco'; -import Docker from '@/components/Icons/Platform/Docker'; -import Generic from '@/components/Icons/Platform/Generic'; -import Homebrew from '@/components/Icons/Platform/Homebrew'; -import Linux from '@/components/Icons/Platform/Linux'; -import Microsoft from '@/components/Icons/Platform/Microsoft'; -import NVM from '@/components/Icons/Platform/NVM'; - -export const PlatformLogos: StoryObj = { - render: () => ( -
-
- - - -
-
- - - -
-
- - -
-
- ), -}; - -export default { title: 'Design System' } as MetaObj; diff --git a/apps/site/components/__design__/social-logos.stories.tsx b/apps/site/components/__design__/social-logos.stories.tsx deleted file mode 100644 index 7f1a5c1288fc9..0000000000000 --- a/apps/site/components/__design__/social-logos.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -import GitHub from '@/components/Icons/Social/GitHub'; -import LinkedIn from '@/components/Icons/Social/LinkedIn'; -import Mastodon from '@/components/Icons/Social/Mastodon'; -import Slack from '@/components/Icons/Social/Slack'; -import Twitter from '@/components/Icons/Social/Twitter'; - -export const SocialMediaLogos: StoryObj = { - render: () => ( -
-
- - - -
-
- - -
-
- ), -}; - -export default { title: 'Design System' } as MetaObj; diff --git a/apps/site/components/__design__/table.stories.tsx b/apps/site/components/__design__/table.stories.tsx deleted file mode 100644 index fb5be4b91ecd4..0000000000000 --- a/apps/site/components/__design__/table.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -export const Table: StoryObj = { - render: () => ( -
- - - - - - - - - - - - - - - - - - - - - - - - - -
Column 1Column 2Column 3
Data 1Data 2Data 3
Data 1Data 2Data 3
Data 1Data 2Data 3
-
- ), -}; - -export const HeadlessTable: StoryObj = { - render: () => ( -
- - - - - - - - - - - - - - - - - - -
Data 1Data 2Data 3
Data 1Data 2Data 3
Data 1Data 2Data 3
-
- ), -}; - -export default { title: 'Design System' } as MetaObj; diff --git a/apps/site/components/__design__/text.stories.tsx b/apps/site/components/__design__/text.stories.tsx deleted file mode 100644 index 9ed6ca21834d8..0000000000000 --- a/apps/site/components/__design__/text.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta as MetaObj, StoryObj } from '@storybook/react'; - -export const Anchor: StoryObj = { - render: () => ( -
-

- The current Node.js security policy can be found at{' '} - - https://github.com/nodejs/node/security/policy#security - - , including information on how to report a vulnerability in Node.js. - Subscribe to the low-volume announcement-only nodejs-sec mailing list at{' '} - - https://groups.google.com/forum/#!forum/nodejs-sec - {' '} - to stay up to date on security vulnerabilities and security-related - releases of Node.js and the projects maintained in the nodejs GitHub - organization. -

-
- ), -}; - -export const InlineCode: StoryObj = { - render: () => ( -
-

- This is an example of inline code block -

-
- ), -}; - -export const Headings: StoryObj = { - render: () => ( -
-

HTML Section Heading 1

-

HTML Section Heading 2

-

HTML Section Heading 3

-

HTML Section Heading 4

-
HTML Section Heading 5
-
HTML Section Heading 6
-
- ), -}; - -export default { title: 'Design System' } as MetaObj; diff --git a/apps/site/components/__mocks__/github-slugger.mjs b/apps/site/components/__mocks__/github-slugger.mjs deleted file mode 100644 index 31a075307647b..0000000000000 --- a/apps/site/components/__mocks__/github-slugger.mjs +++ /dev/null @@ -1,7 +0,0 @@ -class GitHubSlugger { - slug() { - return 'mock-slug'; - } -} - -export default GitHubSlugger; diff --git a/apps/site/components/__mocks__/next-intl.mjs b/apps/site/components/__mocks__/next-intl.mjs deleted file mode 100644 index a84400384381c..0000000000000 --- a/apps/site/components/__mocks__/next-intl.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import Link from 'next/link'; -import { redirect, usePathname, useRouter } from 'next/navigation'; - -export const useMessages = () => ({}); - -export const useNow = () => new Date(); - -export const useTimeZone = () => 'Etc/UTC'; - -export const useTranslations = () => { - const t = key => key; - - t.rich = key => key; - t.markup = key => key; - - return t; -}; - -export const useFormatter = () => { - const formatter = format => new Intl.DateTimeFormat('en-US', format); - - return { - date: (date, format) => formatter(format).format(date), - dateTime: (date, format) => formatter(format).format(date), - }; -}; - -export const NextIntlClientProvider = ({ children }) => children; - -export const createSharedPathnamesNavigation = () => ({ - Link: Link, - redirect: redirect, - usePathname: usePathname, - useRouter: useRouter, -}); diff --git a/apps/site/components/__mocks__/next-router.mjs b/apps/site/components/__mocks__/next-router.mjs deleted file mode 100644 index e4a27c6f49e7f..0000000000000 --- a/apps/site/components/__mocks__/next-router.mjs +++ /dev/null @@ -1,4 +0,0 @@ -export const useRouter = () => ({ - isReady: true, - asPath: '/', -}); diff --git a/apps/site/components/mdxRenderer.tsx b/apps/site/components/mdxRenderer.tsx deleted file mode 100644 index abf9b4315e97e..0000000000000 --- a/apps/site/components/mdxRenderer.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { MDXComponents, MDXContent } from 'mdx/types'; -import type { FC } from 'react'; - -import { htmlComponents, clientMdxComponents } from '@/next.mdx.use.client.mjs'; -import { mdxComponents } from '@/next.mdx.use.mjs'; - -// Combine all MDX Components to be used -const combinedComponents: MDXComponents = { - ...htmlComponents, - ...clientMdxComponents, - ...mdxComponents, -}; - -export const MDXRenderer: FC<{ Component: MDXContent }> = ({ Component }) => ( - -); diff --git a/apps/site/components/withAvatarGroup.tsx b/apps/site/components/withAvatarGroup.tsx new file mode 100644 index 0000000000000..f9c3491dd8c92 --- /dev/null +++ b/apps/site/components/withAvatarGroup.tsx @@ -0,0 +1,34 @@ +'use client'; + +import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup'; + +import Link from '#site/components/Link'; +import { getAuthors } from '#site/util/author'; + +import type { AuthorProps } from '#site/types'; +import type { ComponentProps, FC } from 'react'; + +type WithAvatarGroupProps = Omit< + ComponentProps, + 'avatars' | 'as' +> & + AuthorProps; + +const WithAvatarGroup: FC = ({ + usernames, + names, + clickable = true, + ...props +}) => ( + +); + +export default WithAvatarGroup; diff --git a/apps/site/components/withBadge.tsx b/apps/site/components/withBadge.tsx deleted file mode 100644 index b927605692ab1..0000000000000 --- a/apps/site/components/withBadge.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { FC } from 'react'; - -import Badge from '@/components/Common/Badge'; -import { siteConfig } from '@/next.json.mjs'; -import { dateIsBetween } from '@/util/dateUtils'; - -const WithBadge: FC<{ section: string }> = ({ section }) => { - const badge = siteConfig.websiteBadges[section]; - - if (badge && dateIsBetween(badge.startDate, badge.endDate)) { - return ( - - {badge.text} - - ); - } - - return null; -}; - -export default WithBadge; diff --git a/apps/site/components/withBadgeGroup.tsx b/apps/site/components/withBadgeGroup.tsx new file mode 100644 index 0000000000000..be4263437700c --- /dev/null +++ b/apps/site/components/withBadgeGroup.tsx @@ -0,0 +1,31 @@ +import BadgeGroup from '@node-core/ui-components/Common/BadgeGroup'; + +import Link from '#site/components/Link'; +import { siteConfig } from '#site/next.json.mjs'; +import { dateIsBetween } from '#site/util/date'; + +import type { FC } from 'react'; + +const WithBadgeGroup: FC<{ section: string }> = ({ section }) => { + const badge = siteConfig.websiteBadges[section]; + + if (badge && dateIsBetween(badge.startDate, badge.endDate)) { + return ( + + {badge.text} + + ); + } + + return null; +}; + +export default WithBadgeGroup; diff --git a/apps/site/components/withBanner.tsx b/apps/site/components/withBanner.tsx index af571479ad8f4..b8a186d01b23a 100644 --- a/apps/site/components/withBanner.tsx +++ b/apps/site/components/withBanner.tsx @@ -1,16 +1,52 @@ +'use client'; + +import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; +import Banner from '@node-core/ui-components/Common/Banner'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +import Link from '#site/components/Link'; +import { siteConfig } from '#site/next.json.mjs'; +import { dateIsBetween } from '#site/util/date'; + import type { FC } from 'react'; -import Banner from '@/components/Common/Banner'; -import { siteConfig } from '@/next.json.mjs'; -import { dateIsBetween } from '@/util/dateUtils'; +const STORAGE_KEY = 'banner-dismissal'; const WithBanner: FC<{ section: string }> = ({ section }) => { const banner = siteConfig.websiteBanners[section]; + const t = useTranslations(); + + const [open, setOpen] = useState(false); + + useEffect(() => { + if (banner) { + // eslint-disable-next-line @eslint-react/set-state-in-effect + setOpen(localStorage.getItem(STORAGE_KEY) !== banner.text); + } + }, [banner]); + + if (banner && open && dateIsBetween(banner.startDate, banner.endDate)) { + const bannerType = banner.type || 'default'; + + const onClose = () => { + localStorage.setItem(STORAGE_KEY, banner.text); + setOpen(false); + }; - if (banner && dateIsBetween(banner.startDate, banner.endDate)) { return ( - - {banner.text} + + {banner.link ? ( + {banner.text} + ) : ( + banner.text + )} + {banner.link && } ); } diff --git a/apps/site/components/withBlogCategories.tsx b/apps/site/components/withBlogCategories.tsx index 7be3f6a890a6a..e4b58cebf793c 100644 --- a/apps/site/components/withBlogCategories.tsx +++ b/apps/site/components/withBlogCategories.tsx @@ -1,11 +1,12 @@ import { useTranslations } from 'next-intl'; -import type { ComponentProps, FC } from 'react'; -import BlogPostCard from '@/components/Common/BlogPostCard'; -import LinkTabs from '@/components/Common/LinkTabs'; -import Pagination from '@/components/Common/Pagination'; -import type { BlogPostsRSC } from '@/types'; -import { mapAuthorToCardAuthors } from '@/util/blogUtils'; +import BlogPostCard from '#site/components/Blog/BlogPostCard'; +import LinkTabs from '#site/components/Common/LinkTabs'; +import Pagination from '#site/components/Common/Pagination'; +import { mapAuthorToCardAuthors } from '#site/util/author'; + +import type { BlogPostsRSC } from '#site/types'; +import type { ComponentProps, FC } from 'react'; type WithBlogCategoriesProps = { categories: ComponentProps['tabs']; @@ -30,7 +31,7 @@ const WithBlogCategories: FC = ({ tabs={categories} activeTab={blogData.category} > -
+
{blogData.posts.map(post => ( { +const WithBlogCrossLinks: FC = () => { const { pathname } = getClientContext(); // Extracts from the static URL the components used for the Blog Post slug - const [, , category, postname] = pathname.split('/'); + const [, , category, postname] = pathname.split('/') as [ + unknown, + unknown, + BlogCategory, + string, + ]; - const { posts } = await getBlogData(category); + const { posts } = getBlogData(category); const currentItem = posts.findIndex( ({ slug }) => slug === `/blog/${category}/${postname}` @@ -22,7 +28,7 @@ const WithBlogCrossLinks: FC = async () => { ]; return ( -
+
{(previousCrossLink && ( ; @@ -14,12 +19,13 @@ type WithBreadcrumbsProps = { const WithBreadcrumbs: FC = ({ navKeys = [] }) => { const { getSideNavigation } = useSiteNavigation(); + const t = useTranslations(); const { pathname } = useClientContext(); const isMobileScreen = useMediaQuery('(max-width: 639px)'); const maxLength = isMobileScreen ? 2 : 4; - const getBreadrumbs = () => { + const getBreadcrumbs = () => { const navigationTree = getSideNavigation(navKeys); const pathList = pathname @@ -57,7 +63,14 @@ const WithBreadcrumbs: FC = ({ navKeys = [] }) => { }, [] as Array); }; - return ; + return ( + + ); }; export default WithBreadcrumbs; diff --git a/apps/site/components/withChangelogModal.tsx b/apps/site/components/withChangelogModal.tsx deleted file mode 100644 index 8c6700bdbbd28..0000000000000 --- a/apps/site/components/withChangelogModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import type { FC, ReactElement } from 'react'; -import { VFile } from 'vfile'; - -import ChangelogModal from '@/components/Downloads/ChangelogModal'; -import changelogData from '@/next-data/changelogData'; -import { compileMDX } from '@/next.mdx.compiler.mjs'; -import { clientMdxComponents, htmlComponents } from '@/next.mdx.use.client.mjs'; -import type { NodeRelease } from '@/types'; -import { - getNodeJsChangelogAuthor, - getNodeJsChangelogSlug, -} from '@/util/getNodeJsChangelog'; -import { getGitHubAvatarUrl } from '@/util/gitHubUtils'; - -type WithChangelogModalProps = { - release: NodeRelease; - modalOpen: boolean; - setModalOpen: (open: boolean) => void; -}; - -// We only need the base components for our ChangelogModal, this avoids/eliminates -// the need of Next.js bundling on the client-side all our MDX components -// Note that this already might increase the client-side bundle due to Shiki -const clientComponents = { ...clientMdxComponents, ...htmlComponents }; - -const WithChangelogModal: FC = ({ - release: { versionWithPrefix }, - modalOpen, - setModalOpen, -}) => { - const [ChangelogMDX, setChangelogMDX] = useState(); - const [changelog, setChangelog] = useState(''); - - useEffect(() => { - let isCancelled = false; - - const fetchChangelog = async () => { - try { - const data = await changelogData(versionWithPrefix); - - // We need to check if the component is still mounted before setting the state - if (!isCancelled) { - setChangelog(data); - - // This removes the

header from the changelog content, as we already - // render the changelog heading as the "ChangelogModal" subheading - const changelogWithoutHeader = data.split('\n').slice(2).join('\n'); - - compileMDX(new VFile(changelogWithoutHeader), 'md').then( - ({ MDXContent }) => { - // This is a tricky one. React states does not allow you to actually store React components - // hence we need to render the component within an Effect and set the state as a ReactElement - // which is a function that can be eval'd by React during runtime. - const renderedElement = ( - - ); - - setChangelogMDX(renderedElement); - } - ); - } - } catch (_) { - throw new Error(`Failed to fetch changelog for, ${versionWithPrefix}`); - } - }; - - if (modalOpen && versionWithPrefix) { - fetchChangelog(); - } - - return () => { - isCancelled = true; - }; - }, [modalOpen, versionWithPrefix]); - - const author = getNodeJsChangelogAuthor(changelog); - const slug = getNodeJsChangelogSlug(changelog); - - const modalProps = { - heading: `Node.js ${versionWithPrefix}`, - avatars: [{ src: getGitHubAvatarUrl(author), alt: author }], - subheading: slug, - open: modalOpen && typeof ChangelogMDX !== 'undefined', - onOpenChange: setModalOpen, - }; - - return ( - -
{ChangelogMDX}
-
- ); -}; - -export default WithChangelogModal; diff --git a/apps/site/components/withCurrentOS.tsx b/apps/site/components/withCurrentOS.tsx deleted file mode 100644 index 45fa88ecd127b..0000000000000 --- a/apps/site/components/withCurrentOS.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import type { FC } from 'react'; - -import { useDetectOS } from '@/hooks'; - -type WithCurrentOS = { - children: FC<{ currentOS: ReturnType }>; -}; - -const WithCurrentOS: FC = ({ children: Component }) => { - const osData = useDetectOS(); - - return ; -}; - -export default WithCurrentOS; diff --git a/apps/site/components/withDownloadArchive.tsx b/apps/site/components/withDownloadArchive.tsx new file mode 100644 index 0000000000000..ec9f47b37fa95 --- /dev/null +++ b/apps/site/components/withDownloadArchive.tsx @@ -0,0 +1,46 @@ +import { notFound } from 'next/navigation'; + +import { getClientContext } from '#site/client-context'; +import provideReleaseData from '#site/next-data/providers/releaseData'; +import { + buildReleaseArtifacts, + extractVersionFromPath, +} from '#site/util/download/archive'; + +import type { FC } from 'react'; + +type DownloadArchive = ReturnType; + +type WithDownloadArchiveProps = { + children: FC; +}; + +/** + * Higher-order component that extracts version from pathname, + * fetches release data, and provides download artifacts to child component + */ +const WithDownloadArchive: FC = async ({ + children: Component, +}) => { + const { pathname } = getClientContext(); + + // Extract version from pathname + const version = extractVersionFromPath(pathname); + + // Find the release data for the given version + const releaseData = await provideReleaseData(); + const release = releaseData.find(release => + // Match major version only (e.g., v22.x.x for release.major v22) + version.startsWith(`v${release.major}`) + )!; + + if (!release) { + return notFound(); + } + + const releaseArtifacts = buildReleaseArtifacts(release, version); + + return ; +}; + +export default WithDownloadArchive; diff --git a/apps/site/components/withDownloadCategories.tsx b/apps/site/components/withDownloadCategories.tsx deleted file mode 100644 index 4f85137d7e9f8..0000000000000 --- a/apps/site/components/withDownloadCategories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getTranslations } from 'next-intl/server'; -import type { FC, PropsWithChildren } from 'react'; - -import LinkTabs from '@/components/Common/LinkTabs'; -import WithNodeRelease from '@/components/withNodeRelease'; -import { useClientContext } from '@/hooks/react-server'; -import getReleaseData from '@/next-data/releaseData'; -import { ReleaseProvider } from '@/providers/releaseProvider'; -import type { NodeReleaseStatus } from '@/types'; -import { getDownloadCategory, mapCategoriesToTabs } from '@/util/downloadUtils'; - -const WithDownloadCategories: FC = async ({ children }) => { - const t = await getTranslations(); - const releases = await getReleaseData(); - - const { pathname } = useClientContext(); - const { page, category, subCategory } = getDownloadCategory(pathname); - - const initialRelease: NodeReleaseStatus = pathname.includes('current') - ? 'Current' - : 'LTS'; - - return ( - - - {({ release }) => ( - - {children} - - )} - - - ); -}; - -export default WithDownloadCategories; diff --git a/apps/site/components/withDownloadSection.tsx b/apps/site/components/withDownloadSection.tsx new file mode 100644 index 0000000000000..373eebaafb290 --- /dev/null +++ b/apps/site/components/withDownloadSection.tsx @@ -0,0 +1,55 @@ +import { defaultLocale } from '@node-core/website-i18n'; +import { getLocale } from 'next-intl/server'; + +import { getClientContext } from '#site/client-context'; +import WithNodeRelease from '#site/components/withNodeRelease'; +import provideDownloadSnippets from '#site/next-data/providers/downloadSnippets'; +import { + ReleaseProvider, + ReleasesProvider, +} from '#site/providers/releaseProvider'; + +import type { NodeRelease } from '../types'; +import type { FC, PropsWithChildren } from 'react'; + +type WithDownloadSectionProps = PropsWithChildren<{ + releases: Array; +}>; + +const WithDownloadSection: FC = async ({ + releases, + children, +}) => { + const locale = await getLocale(); + + const snippets = await provideDownloadSnippets(); + + const localeSnippets = snippets.get(locale) ?? []; + + // By default the translated languages do not contain all the download snippets + // Hence we always merge any translated snippet with the fallbacks for missing snippets + const fallbackSnippets = snippets.get(defaultLocale.code) ?? []; + + const { pathname } = getClientContext(); + + // Some available translations do not have download snippets translated or have them partially translated + // This aims to merge the available translated snippets with the fallback snippets + const memoizedSnippets = fallbackSnippets + .filter(snippet => !localeSnippets.some(s => s.name === snippet.name)) + .concat(localeSnippets); + + // Decides which initial release to use based on the current pathname + const initialRelease = pathname.endsWith('/current') ? 'Current' : 'LTS'; + + return ( + + {({ release }) => ( + + {children} + + )} + + ); +}; + +export default WithDownloadSection; diff --git a/apps/site/components/withFooter.tsx b/apps/site/components/withFooter.tsx index abdc64b295f53..148aa7e65e08f 100644 --- a/apps/site/components/withFooter.tsx +++ b/apps/site/components/withFooter.tsx @@ -1,7 +1,69 @@ +import BadgeGroup from '@node-core/ui-components/Common/BadgeGroup'; +import Footer from '@node-core/ui-components/Containers/Footer'; +import { useTranslations } from 'next-intl'; + +import { getClientContext } from '#site/client-context'; +import Link from '#site/components/Link'; +import { siteNavigation } from '#site/next.json.mjs'; + import type { FC } from 'react'; -import Footer from '@/components/Containers/Footer'; +import WithLegal from './withLegal'; +import WithNodeRelease from './withNodeRelease'; + +const WithFooter: FC = () => { + const t = useTranslations(); + const { pathname } = getClientContext(); + + const { socialLinks, footerLinks } = siteNavigation; + + const navigation = { + socialLinks, + footerLinks: footerLinks.map(link => ({ + ...link, + translation: t(link.text), + })), + }; + + const primary = ( +
+ + {({ release }) => ( + + {t('components.containers.footer.releasePills.latestLTS')} + + )} + + + + {({ release }) => ( + + {t('components.containers.footer.releasePills.latestRelease')} + + )} + +
+ ); + + const legal = ; -const WithFooter: FC = () =>