diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 4c7030bd7a..d633738694 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -22,25 +22,27 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - - name: ๐ŸŽจ Check for non-RTL/non-a11y CSS classes - run: vp run lint:css + - parallel: + - name: ๐ŸŽจ Check for non-RTL/non-a11y CSS classes + run: vp run lint:css - - name: ๐ŸŒ Compare translations - run: vp run i18n:check + - name: ๐ŸŒ Compare translations + run: vp run i18n:check - - name: ๐ŸŒ Update lunaria data - run: vp run build:lunaria + - name: ๐ŸŒ Update lunaria data + run: vp run build:lunaria - - name: ๐Ÿ”  Fix lint errors - run: vp run lint:fix + - name: ๐Ÿ”  Fix lint errors + run: vp run lint:fix - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2 diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 1cbf9719b8..97b46d7d0e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -20,23 +20,24 @@ jobs: steps: - name: โ˜‘๏ธ Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true - - - name: ๐ŸŸง Install pnpm globally - run: vp install -g pnpm + sfw: true - name: ๐Ÿงช Run Chromatic Visual and Accessibility Tests - uses: chromaui/action@8a2b82547aef5a3efc8ec3c7905f4ab09a76ed0b # v16.1.0 + uses: chromaui/action@7804f34e4e59c0d9b3c856848f46ad96d7897429 # v17.5.0 + with: + buildCommand: vp run build-storybook + outputDir: storybook-static env: CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37bd15e85f..554607d6a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,17 +26,17 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* - run-install: false - - - name: ๐Ÿ“ฆ Install dependencies (root only, no scripts) - run: vp install --filter . --ignore-scripts + sfw: true + # root only, no scripts + run-install: | + - args: ['--filter', '.', '--ignore-scripts'] - name: ๐Ÿ”  Lint project run: vp run lint @@ -46,14 +46,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐Ÿ’ช Type check run: vp run test:types @@ -63,21 +64,22 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐Ÿงช Unit tests run: vp test --project unit --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml - name: โฌ†๏ธŽ Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true files: test-report.junit.xml @@ -88,7 +90,7 @@ jobs: - name: โฌ†๏ธŽ Upload coverage reports to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true files: coverage/clover.xml @@ -101,14 +103,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐ŸŒ Install browser run: vp exec playwright install chromium-headless-shell @@ -118,7 +121,7 @@ jobs: - name: โฌ†๏ธŽ Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true files: test-report.junit.xml @@ -129,7 +132,7 @@ jobs: - name: โฌ†๏ธŽ Upload coverage reports to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true files: coverage/clover.xml @@ -144,17 +147,18 @@ jobs: image: mcr.microsoft.com/playwright:v1.60.0-noble@sha256:9bd26ad900bb5e0f4dee75839e957a89ae89c2b7ab1e76050e559790e946b948 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: ๐Ÿ‘‘ Fix Git ownership run: git config --global --add safe.directory /__w/npmx.dev/npmx.dev - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐Ÿ—๏ธ Build project run: vp run build:test @@ -166,7 +170,7 @@ jobs: - name: โฌ†๏ธŽ Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true files: test-report.junit.xml @@ -184,14 +188,15 @@ jobs: mode: [dark, light] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐Ÿ—๏ธ Build project run: vp run build:test @@ -207,14 +212,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: ๐Ÿงน Check for unused code run: vp run knip @@ -224,17 +230,17 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* - run-install: false - - - name: ๐Ÿ“ฆ Install dependencies (root only, no scripts) - run: vp install --filter . --ignore-scripts + sfw: true + # root only, no scripts + run-install: | + - args: ['--filter', '.', '--ignore-scripts'] - name: ๐ŸŒ Check for missing or dynamic i18n keys run: vp run i18n:report diff --git a/.github/workflows/dependency-diff.yml b/.github/workflows/dependency-diff.yml index ef8984539f..fca4e75c86 100644 --- a/.github/workflows/dependency-diff.yml +++ b/.github/workflows/dependency-diff.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-slim steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/deploy-canary.yml b/.github/workflows/deploy-canary.yml index 52ec4f761f..05f270ef9a 100644 --- a/.github/workflows/deploy-canary.yml +++ b/.github/workflows/deploy-canary.yml @@ -17,16 +17,23 @@ jobs: name: ๐Ÿš€ Deploy to canary (main.npmx.dev) runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + fetch-depth: 2 - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* run-install: false - - run: vp install -g vercel + - uses: SocketDev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: firewall-free + # Version of the underlying sfw to use (no `v` prefix): https://github.com/SocketDev/sfw-free/releases + firewall-version: 1.12.0 + + - run: sfw vp i -g vercel@54.12.2 - run: vercel deploy --target=canary env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/.github/workflows/lunaria.yml b/.github/workflows/lunaria.yml index e4fc52f092..58520eb038 100644 --- a/.github/workflows/lunaria.yml +++ b/.github/workflows/lunaria.yml @@ -22,17 +22,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Necessary for Lunaria to work properly # Makes the action clone the entire git history fetch-depth: 0 persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* cache: true + sfw: true - name: Generate Lunaria Overview uses: lunariajs/action@4911ad0736d1e3b20af4cb70f5079aea2327ed8e # astro-docs diff --git a/.github/workflows/mirror-tangled.yml b/.github/workflows/mirror-tangled.yml index adae34ae0c..2e6e083e6b 100644 --- a/.github/workflows/mirror-tangled.yml +++ b/.github/workflows/mirror-tangled.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index fdb614e08e..5053df0db9 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -21,12 +21,12 @@ jobs: pull-requests: write # create or update the release pull request steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* run-install: false diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e6d050daa0..35a184a3d4 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -23,12 +23,12 @@ jobs: skipped: ${{ steps.check.outputs.skip }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: true - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* run-install: false @@ -66,7 +66,11 @@ jobs: - name: ๐Ÿ“ฆ Install dependencies if: steps.check.outputs.skip == 'false' - run: vp install --filter . --ignore-scripts + uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 + with: + sfw: true + run-install: | + - args: ['--filter', '.', '--ignore-scripts'] - name: ๐Ÿ“ Generate release notes if: steps.check.outputs.skip == 'false' @@ -96,19 +100,23 @@ jobs: environment: npm-publish steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: release persist-credentials: false - - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0 + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version: lts/* registry-url: https://registry.npmjs.org run-install: false - name: ๐Ÿ“ฆ Install dependencies - run: vp install --filter npmx-connector... + uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 + with: + sfw: true + run-install: | + - args: ['--filter', 'npmx-connector...'] - name: ๐Ÿ”ข Set connector version env: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index e3454a5dbe..8c317ef6d1 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,7 +26,7 @@ jobs: contents: read # checkout repository steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 273a9114d2..600359efcb 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -4,7 +4,7 @@ rules: stale-action-refs: ignore: # lunariajs/action has no tag refs; keep the branch commit hash-pinned. - - lunaria.yml:38 + - lunaria.yml:39 dangerous-triggers: ignore: - enforce-release-source.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 254c6385b7..237d15a28d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,7 @@ This focus helps guide our project decisions as a community and what we choose t - [Configuration](#configuration) - [Global app settings](#global-app-settings) - [Known limitations](#known-limitations) +- [Adding a noodle](#adding-a-noodle) - [Submitting changes](#submitting-changes) - [Before submitting](#before-submitting) - [Pull request process](#pull-request-process) @@ -434,21 +435,24 @@ The command palette is a first-class navigation surface. When you add a new user #### Available route names -| Route name | URL pattern | Parameters | -| ----------------- | --------------------------------- | ------------------------- | -| `index` | `/` | — | -| `about` | `/about` | — | -| `compare` | `/compare` | — | -| `privacy` | `/privacy` | — | -| `search` | `/search` | — | -| `settings` | `/settings` | — | -| `package` | `/package/:org?/:name` | `org?`, `name` | -| `package-version` | `/package/:org?/:name/v/:version` | `org?`, `name`, `version` | -| `code` | `/package-code/:path+` | `path` (array) | -| `docs` | `/package-docs/:path+` | `path` (array) | -| `org` | `/org/:org` | `org` | -| `~username` | `/~:username` | `username` | -| `~username-orgs` | `/~:username/orgs` | `username` | +| Route name | URL pattern | Parameters | +| ------------------- | ------------------------------------------------- | -------------------------------- | +| `index` | `/` | — | +| `about` | `/about` | — | +| `compare` | `/compare` | — | +| `privacy` | `/privacy` | — | +| `search` | `/search` | — | +| `settings` | `/settings` | — | +| `package` | `/package/:org?/:name` | `org?`, `name` | +| `package-version` | `/package/:org?/:name/v/:version` | `org?`, `name`, `version` | +| `code` | `/package-code/:path+` | `path` (array) | +| `docs` | `/package-docs/:path+` | `path` (array) | +| `changelog` | `/package-changelog/:org?/:name` | `org?`, `name` | +| `changelog-version` | `/package-changelog/:org?/:name/v/:version` | `org?`, `name`, `version` | +| `timeline` | `/package-timeline/:org?/:packageName/v/:version` | `org?`, `packageName`, `version` | +| `org` | `/org/:org` | `org` | +| `~username` | `/~:username` | `username` | +| `~username-orgs` | `/~:username/orgs` | `username` | ### Cursor and navigation @@ -1045,6 +1049,116 @@ Global application settings are added to the Storybook toolbar for easy testing - `pnpm storybook` may log warnings or non-breaking errors for Nuxt modules due to the lack of mocks. If the UI renders correctly, these can be safely ignored. - Do not `import type` from `.vue` files. The `vue-docgen-api` parser used by `@storybook/addon-docs` cannot follow type imports across SFCs and will crash. Extract shared types into a separate `.ts` file instead. +## Adding a noodle + +"Noodles" are the occasional themed npmx logos that celebrate a day or event (Pride Month, World Tetris Day, the Node.js birthday, โ€ฆ). A noodle has two homes: + +- the **homepage**, where it temporarily replaces the logo while it is active, and +- the **`/noodles` archive**, where every past noodle lives on permanently with its own detail page. + +The two are wired up independently, so shipping a noodle to the homepage **and** the archive in the same PR keeps things in sync. Do both at once. + +### 1. Add the logo component + +Create `app/components/Noodle//Logo.vue`. +It's recommended to add a tooltip with event information to each noodle. If the poster is an image, add it to the `public/extra` directory. If the image differs between light and dark modes - prefer `ColorSchemeImg` component for poster + +```vue + +``` + +### 2. Register the logo and show it on the homepage + +In `app/components/Noodle/index.ts`: + +- import the component, and +- add it to the homepage rotation โ€” `ACTIVE_NOODLES` for a date-bound noodle, or `PERMANENT_NOODLES` for one shown only behind a query param (e.g. `?kawaii`), and +- register it in the `NOODLE_LOGOS` map so the archive can resolve it by `key`. + +```ts +import NoodleTetrisLogo from './Tetris/Logo.vue' + +export const ACTIVE_NOODLES: Noodle[] = [ + { + key: 'tetris', + logo: NoodleTetrisLogo, + date: '2026-06-06', + dateTo: '2026-06-08', + timezone: 'auto', // visitor's local time; or an IANA name like 'America/Los_Angeles' + tagline: false, // hide the npmx tagline while active + }, +] + +const NOODLE_LOGOS: Record = { + // โ€ฆ + tetris: NoodleTetrisLogo, +} +``` + +> [!IMPORTANT] +> The `date` and `dateTo` keys are inclusive, meaning they specify the start (at 00:00) and end (at 23:59) dates. If the dates overlap, a noodle will be randomly selected on each visit. + +> [!IMPORTANT] +> The `key` here must exactly match the `key` of the archive entry you add in the next step โ€” that is how the archive looks up the logo. + +### 3. Add the archive entry + +Append an entry to `app/noodles.ts`. Only `key`, `title`, `slug`, and `date` are required; every other field is optional, and the detail page renders a section only for the fields you fill in. The list is sorted by date automatically. + +```ts +{ + key: 'tetris', + title: 'World Tetris Day', + slug: 'tetris', // becomes /noodles/tetris โ€” must be unique + date: '2026-06-06', + dateTo: '2026-06-08', + timezone: 'auto', + tagline: false, + occasion: 'The legendary console turns 42. โ€ฆ', + prUrl: 'https://github.com/npmx-dev/npmx.dev/pull/2855', + authors: [ALEX], // reuse the author consts at the top of the file + posterImage: '/extra/tetris.svg', // OG-image hero + references: [{ label: 'Tetris (1984)', url: 'https://en.wikipedia.org/wiki/Tetris' }], +}, +``` + +Authors link out to Bluesky via their `blueskyHandle`. Reuse an existing author const (`ALEX`, `ALFON`, โ€ฆ) or add a new one at the top of the file. + +#### Grouping a series with `variants` + +When a single occasion ships as several rotating designs (Pride Month, for example, cycles through three logos), add **one** archive entry rather than one per design. Point `posterImage` at the lead artwork and list the others in `variants` โ€” the detail page's lens carousel shows the registered logo first, then each variant image: + +```ts +{ + key: 'pride-1', // matches the registered NOODLE_LOGOS key (renders first in the lens) + title: 'Pride Month', + slug: 'pride', + // โ€ฆ + posterImage: '/extra/pride-1.svg', + variants: ['/extra/pride-2.svg', '/extra/pride-3.png'], +}, +``` + +### 4. Verify + +```bash +pnpm test:types +``` + +Then run `pnpm dev` and confirm the card appears at `/noodles` and its detail page renders at `/noodles/`. Archive routes are prerendered by crawling links from the index page, so no route list needs editing. + ## Submitting changes ### Before submitting diff --git a/README.md b/README.md index d74939ad29..0312364812 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ What npmx offers: - **Dark mode and light mode** – plus customize the color palette to your preferences - **Translated interface** – localized UI across 39+ locales, including RTL support - **First-class accessibility** – accessible components, keyboard workflows, and automated axe/Lighthouse checks -- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, and timelines +- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, stats and timelines - **Fast search** – quick package search with instant results - **Package details** – READMEs, versions, dependencies, and metadata - **Code viewer** – browse package source code with syntax highlighting and permalink to specific lines @@ -186,6 +186,7 @@ We welcome contributions – please do feel free to explore the project and - [npmx-weekly](https://npmx-weekly.trueberryless.org/) – A weekly newsletter for the npmx ecosystem. Add your own content via suggestions in the weekly PR on [GitHub](https://github.com/trueberryless-org/npmx-weekly/pulls?q=is%3Aopen+is%3Apr+label%3A%22%F0%9F%95%94+weekly+post%22). - [npmx-digest](https://npmx-digest.trueberryless.org/) – An automated news aggregation website that summarizes npmx activity from GitHub and Bluesky every 8 hours. - [npmx-badge](https://npmx-badge.vercel.app/) – A playground to help you create custom badges quickly. +- [npmxers](https://npmxers.trueberryless.org/) – Discover the number of contributions you made to npmx and share your npmxer profile. If you're building something cool, let us know! ๐Ÿ™ @@ -204,3 +205,9 @@ Published under [MIT License](./LICENSE). Star History Chart + +## Sponsors + + + Sponsors + diff --git a/app/assets/logos/sponsors/coderabbit-light.svg b/app/assets/logos/sponsors/coderabbit-light.svg new file mode 100644 index 0000000000..01abf42273 --- /dev/null +++ b/app/assets/logos/sponsors/coderabbit-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/logos/sponsors/coderabbit.svg b/app/assets/logos/sponsors/coderabbit.svg new file mode 100644 index 0000000000..84d0fe7f3f --- /dev/null +++ b/app/assets/logos/sponsors/coderabbit.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/assets/logos/sponsors/index.ts b/app/assets/logos/sponsors/index.ts index 07d5532534..6a4347eeee 100644 --- a/app/assets/logos/sponsors/index.ts +++ b/app/assets/logos/sponsors/index.ts @@ -12,6 +12,8 @@ import LogoBadrap from './badrap.svg' import LogoBadrapLight from './badrap-light.svg' import LogoChromatic from './chromatic.svg' import LogoChromaticLight from './chromatic-light.svg' +import LogoCodeRabbit from './coderabbit.svg' +import LogoCodeRabbitLight from './coderabbit-light.svg' // The list is used on the about page. To add, simply upload the logos nearby and add an entry here. Prefer SVGs. // For logo src, specify a string or object with the light and dark theme variants. @@ -20,68 +22,81 @@ import LogoChromaticLight from './chromatic-light.svg' // If there are no original assets and the logo is not universal, you can add only the dark theme variant // and specify 'auto' for the light one - this will grayscale the logo and invert it in light mode. // The normalisingIndent is the Y-axis space to visually stabilize favicon-only logos with logotypes that contain long name. -export const SPONSORS = [ - { - name: 'Vercel', - logo: { - dark: LogoVercel, - light: LogoVercelLight, +export const SPONSORS = { + gold: [ + { + name: 'Vercel', + logo: { + dark: LogoVercel, + light: LogoVercelLight, + }, + normalisingIndent: '0.875rem', + url: 'https://vercel.com/', }, - normalisingIndent: '0.875rem', - url: 'https://vercel.com/', - }, - { - name: 'Void Zero', - logo: { - dark: LogoVoidZero, - light: LogoVoidZeroLight, + { + name: 'CodeRabbit', + logo: { + dark: LogoCodeRabbit, + light: LogoCodeRabbitLight, + }, + normalisingIndent: '0.875rem', + url: 'https://www.coderabbit.ai', }, - normalisingIndent: '0.875rem', - url: 'https://voidzero.dev/', - }, - { - name: 'vlt', - logo: { - dark: LogoVlt, - light: LogoVltLight, + ], + silver: [ + { + name: 'Void Zero', + logo: { + dark: LogoVoidZero, + light: LogoVoidZeroLight, + }, + normalisingIndent: '0.875rem', + url: 'https://voidzero.dev/', }, - normalisingIndent: '0.875rem', - url: 'https://vlt.sh/', - }, - { - name: 'Netlify', - logo: { - dark: LogoNetlify, - light: LogoNetlifyLight, + { + name: 'vlt', + logo: { + dark: LogoVlt, + light: LogoVltLight, + }, + normalisingIndent: '0.875rem', + url: 'https://vlt.sh/', }, - normalisingIndent: '0.25rem', - url: 'https://netlify.com/', - }, - { - name: 'Bluesky', - logo: { - dark: LogoBluesky, - light: LogoBlueskyLight, + { + name: 'Netlify', + logo: { + dark: LogoNetlify, + light: LogoNetlifyLight, + }, + normalisingIndent: '0.25rem', + url: 'https://netlify.com/', }, - normalisingIndent: '0.625rem', - url: 'https://bsky.app/', - }, - { - name: 'Chromatic', - logo: { - dark: LogoChromatic, - light: LogoChromaticLight, + { + name: 'Bluesky', + logo: { + dark: LogoBluesky, + light: LogoBlueskyLight, + }, + normalisingIndent: '0.625rem', + url: 'https://bsky.app/', }, - normalisingIndent: '0.5rem', - url: 'https://chromatic.com/', - }, - { - name: 'Badrap', - logo: { - dark: LogoBadrap, - light: LogoBadrapLight, + { + name: 'Chromatic', + logo: { + dark: LogoChromatic, + light: LogoChromaticLight, + }, + normalisingIndent: '0.5rem', + url: 'https://chromatic.com/', }, - normalisingIndent: '0.5rem', - url: 'https://badrap.io/', - }, -] + { + name: 'Badrap', + logo: { + dark: LogoBadrap, + light: LogoBadrapLight, + }, + normalisingIndent: '0.5rem', + url: 'https://badrap.io/', + }, + ], +} diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 179e9b1d64..ccda96c567 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -1,9 +1,10 @@ + ${lastPlotValues} + ${logo} + ` + }, + }) +} diff --git a/server/utils/import-resolver.ts b/server/utils/import-resolver.ts index 1740b6facd..db42226092 100644 --- a/server/utils/import-resolver.ts +++ b/server/utils/import-resolver.ts @@ -101,6 +101,48 @@ function getExtensionPriority(sourceFile: string): string[][] { return [[], ['.ts', '.js'], ['.d.ts'], ['.json']] } +/** + * Resolve an alias specifier to the directory path within a file path. + * Supports #, ~, @, and $ prefixes (e.g. #app, ~/app, @/app, $/app). + * The alias must match a path segment exactly (no partial matches). + */ +export function resolveAliasToDir(aliasSpec: string, filePath?: string | null): string | null { + if ( + (!aliasSpec.startsWith('#') && + !aliasSpec.startsWith('~') && + !aliasSpec.startsWith('@') && + !aliasSpec.startsWith('$')) || + !filePath + ) { + return null + } + + // Support #app, #/app, ~app, ~/app, @app, @/app, $app, $/app + const alias = aliasSpec.replace(/^[#~@$]\/?/, '') + const normalizedFilePath = filePath.replace(/\/+$/, '') + if (!normalizedFilePath) { + return null + } + + if (alias === '') { + return normalizedFilePath + } + + const segments = normalizedFilePath.split('/') + let lastMatchIndex = -1 + for (let i = 0; i < segments.length; i++) { + if (segments[i] === alias) { + lastMatchIndex = i + } + } + + if (lastMatchIndex === -1) { + return null + } + + return segments.slice(0, lastMatchIndex + 1).join('/') +} + /** * Get index file extensions to try for directory imports. */ @@ -131,6 +173,23 @@ export interface ResolvedImport { path: string } +export type InternalImportTarget = string | { default?: string; import?: string } | null | undefined + +export type InternalImportsMap = Record + +export type PackageExportTarget = + | string + | { + default?: string + import?: string + require?: string + types?: string + } + | null + | undefined + +export type PackageExportsMap = Record + /** * Resolve a relative import specifier to an actual file path. * @@ -198,6 +257,253 @@ export function resolveRelativeImport( return null } +function normalizeInternalImportTarget(target: InternalImportTarget): string | null { + if (typeof target === 'string') { + return target + } + + if (target && typeof target === 'object') { + if (typeof target.import === 'string') { + return target.import + } + + if (typeof target.default === 'string') { + return target.default + } + } + + return null +} + +function normalizeAliasPrefix(value: string): string { + return value.replace(/^([#~@$])\//, '$1') +} + +function guessInternalImportTarget( + imports: InternalImportsMap, + specifier: string, + files: FileSet, + currentFile: string, +): string | null { + const normalizedSpecifier = normalizeAliasPrefix(specifier) + + for (const [key, value] of Object.entries(imports)) { + const normalizedKey = normalizeAliasPrefix(key) + if ( + normalizedSpecifier === normalizedKey || + normalizedSpecifier.startsWith(`${normalizedKey}/`) + ) { + const basePath = resolveAliasToDir(key, normalizeInternalImportTarget(value)) + if (!basePath) continue + + const suffix = normalizedSpecifier.slice(normalizedKey.length).replace(/^\//, '') + const pathWithoutExt = suffix ? `${basePath}/${suffix}` : basePath + + const toCheckPath = (p: string) => files.has(normalizePath(p)) || files.has(p) + + // Path already has an extension-like suffix on the last segment - return as is if exists + const filename = pathWithoutExt.split('/').pop() ?? '' + if (filename.includes('.') && !filename.endsWith('.')) { + if (toCheckPath(pathWithoutExt)) { + return pathWithoutExt.startsWith('./') ? pathWithoutExt : `./${pathWithoutExt}` + } + return null + } + + // Try adding extensions based on currentFile type + const extensionGroups = getExtensionPriority(currentFile) + for (const extensions of extensionGroups) { + if (extensions.length === 0) { + if (toCheckPath(pathWithoutExt)) { + return pathWithoutExt.startsWith('./') ? pathWithoutExt : `./${pathWithoutExt}` + } + } else { + for (const ext of extensions) { + const pathWithExt = pathWithoutExt + ext + if (toCheckPath(pathWithExt)) { + return pathWithExt.startsWith('./') ? pathWithExt : `./${pathWithExt}` + } + } + } + } + + // Try as directory with index file + for (const indexFile of getIndexExtensions(currentFile)) { + const indexPath = `${pathWithoutExt}/${indexFile}` + if (toCheckPath(indexPath)) { + return indexPath.startsWith('./') ? indexPath : `./${indexPath}` + } + } + } + } + return null +} + +/** + * import ... from '#components/Button.vue' + * import ... from '#/components/Button.vue' + * import ... from '~/components/Button.vue' + * import ... from '~components/Button.vue' + * import ... from '$components/Button.vue' + * import ... from '$/components/Button.vue' + */ +export function resolveInternalImport( + specifier: string, + currentFile: string, + imports: InternalImportsMap | undefined, + files: FileSet, +): ResolvedImport | null { + const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() + + if ( + (!cleanSpecifier.startsWith('#') && + !cleanSpecifier.startsWith('~') && + !cleanSpecifier.startsWith('@') && + !cleanSpecifier.startsWith('$')) || + !imports + ) { + return null + } + + const importTarget = normalizeInternalImportTarget(imports[cleanSpecifier]) + const target = + importTarget != null + ? importTarget + : guessInternalImportTarget(imports, cleanSpecifier, files, currentFile) + + if (!target || !target.startsWith('./')) { + return null + } + + const path = normalizePath(target) + if (!path || path.startsWith('..')) { + return null + } + + if (files.has(path)) { + return { path } + } + + for (const extensions of getExtensionPriority(currentFile)) { + for (const ext of extensions) { + const candidate = `${path}${ext}` + if (files.has(candidate)) { + return { path: candidate } + } + } + } + + for (const indexFile of getIndexExtensions(currentFile)) { + const candidate = `${path}/${indexFile}` + if (files.has(candidate)) { + return { path: candidate } + } + } + + return null +} + +function normalizePackageSubpath(specifier: string, packageName: string): string | null { + const cleanSpecifier = specifier.replace(/^['"]|['"]$/g, '').trim() + + if (cleanSpecifier === packageName) { + return '.' + } + + if (!cleanSpecifier.startsWith(`${packageName}/`)) { + return null + } + + return `.${cleanSpecifier.slice(packageName.length)}` +} + +function normalizePackageExportTarget(target: PackageExportTarget): string | null { + if (typeof target === 'string') { + return target + } + + if (target && typeof target === 'object') { + if (typeof target.import === 'string') { + return target.import + } + + if (typeof target.default === 'string') { + return target.default + } + + if (typeof target.require === 'string') { + return target.require + } + + if (typeof target.types === 'string') { + return target.types + } + } + + return null +} + +export function resolvePackageSelfImport( + specifier: string, + packageName: string, + exportsMap: PackageExportsMap | undefined, + currentFile: string, + files: FileSet, +): ResolvedImport | null { + const exportKey = normalizePackageSubpath(specifier, packageName) + if (!exportKey) { + return null + } + + const resolvePath = (path: string): ResolvedImport | null => { + if (!path || path.startsWith('..')) { + return null + } + + if (files.has(path)) { + return { path } + } + + for (const extensions of getExtensionPriority(currentFile)) { + for (const ext of extensions) { + const candidate = `${path}${ext}` + if (files.has(candidate)) { + return { path: candidate } + } + } + } + + for (const indexFile of getIndexExtensions(currentFile)) { + const candidate = `${path}/${indexFile}` + if (files.has(candidate)) { + return { path: candidate } + } + } + + return null + } + + const target = exportsMap ? normalizePackageExportTarget(exportsMap[exportKey]) : null + if (target?.startsWith('./')) { + const resolvedFromExports = resolvePath(normalizePath(target)) + if (resolvedFromExports) { + return resolvedFromExports + } + } + + // Fallback for packages whose exports could not be fetched at runtime: + // map `pkg/subpath` to `subpath` and apply normal extension/index resolution. + if (exportKey !== '.') { + const fallbackPath = normalizePath(exportKey.slice(2)) + const resolvedFallback = resolvePath(fallbackPath) + if (resolvedFallback) { + return resolvedFallback + } + } + + return null +} + /** * Create a resolver function bound to a specific file tree and current file. */ @@ -206,9 +512,21 @@ export function createImportResolver( currentFile: string, packageName: string, version: string, + internalImports?: InternalImportsMap, + exportsMap?: PackageExportsMap, ): (specifier: string) => string | null { return (specifier: string) => { - const resolved = resolveRelativeImport(specifier, currentFile, files) + const relativeResolved = resolveRelativeImport(specifier, currentFile, files) + const internalResolved = resolveInternalImport(specifier, currentFile, internalImports, files) + const selfResolved = resolvePackageSelfImport( + specifier, + packageName, + exportsMap, + currentFile, + files, + ) + const resolved = relativeResolved ?? internalResolved ?? selfResolved + if (resolved) { return `/package-code/${packageName}/v/${version}/${resolved.path}` } diff --git a/server/utils/mdKit.ts b/server/utils/mdKit.ts new file mode 100644 index 0000000000..3ebe367d95 --- /dev/null +++ b/server/utils/mdKit.ts @@ -0,0 +1,561 @@ +import { + type Tokens, + type RendererApi, + type Renderer, + type TokenizerObject, + type Marked, + marked, +} from 'marked' +import { highlightCodeSync } from './shiki' +import { decodeHtmlEntities, stripHtmlTags, slugify } from '#shared/utils/html' +import { escapeHtml } from './docs/text' +import sanitizeHtml from 'sanitize-html' +import { hasProtocol } from 'ufo' + +/// for marked + +// constands +const npmJsHosts = new Set(['www.npmjs.com', 'npmjs.com', 'www.npmjs.org', 'npmjs.org']) + +/** These path on npmjs.com don't belong to packages or search, so we shouldn't try to replace them with npmx.dev urls */ +const reservedPathsNpmJs = [ + 'products', + 'login', + 'signup', + 'advisories', + 'blog', + 'about', + 'press', + 'policies', +] + +// blockquote & code + +/** + * GitHub-style callouts: > [!NOTE], > [!TIP], etc. + */ +export const blockquote: RendererApi['blockquote'] = function ( + this: Renderer, + { tokens }, +) { + const body = this.parser.parse(tokens) + + const calloutMatch = body.match(/^

\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:
)?\s*/i) + + if (calloutMatch?.[1]) { + const calloutType = calloutMatch[1].toLowerCase() + const cleanedBody = body.replace(calloutMatch[0], '

') + return `

${cleanedBody}
\n` + } + + return `
${body}
\n` +} + +/** + * created code highlighter with Shiki for Marked + */ +export async function createCodeHighlighter(): Promise { + const shiki = await getShikiHighlighter() + + // Syntax highlighting for code blocks (uses shared highlighter) + return ({ text, lang }: Tokens.Code) => { + const html = highlightCodeSync(shiki, text, lang || 'text') + // Add copy button + return `
+ + ${html} +
` + } +} + +// link +export type ProcessLinkFn = ( + href: string, + label: string, + // readme.ts also needs the extraAttrs for more things, so can't be a boolean +) => { resolvedHref: string; extraAttrs: string } + +export function decodeHashFragment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +const EMAIL_REGEX = /^[\w+\-.]+@[\w\-.]+\.[a-z]+$/i + +/** + * Resolve link URLs, add security attributes, and collect playground links + * + * โ€” all in a single pass during marked rendering (no deferred processing) + */ +export function createLink(processLink: ProcessLinkFn): RendererApi['link'] { + return function (this: Renderer, { href, title, tokens }: Tokens.Link) { + const eTitle = escapeHtml(title ?? '') + const text = this.parser.parseInline(tokens) + const titleAttr = eTitle ? ` title="${eTitle}"` : '' + let plainText = stripHtmlTags(text).trim() + + // If plain text is empty, check if we have an image with alt text + if (!plainText && tokens.length === 1 && tokens[0]?.type === 'image') { + plainText = tokens[0].text + } + + const { resolvedHref, extraAttrs } = processLink(href, plainText || eTitle || '') + + if (!resolvedHref) return text + + // prevents package@1.0.0 being made into an email + if (href.startsWith('mailto:') && !EMAIL_REGEX.test(plainText)) { + return text + } + + return `${text}` + } +} + +export const isNpmJsUrlThatCanBeRedirected = (url: URL) => { + if (!npmJsHosts.has(url.host)) { + return false + } + + if ( + url.pathname === '/' || + reservedPathsNpmJs.some(path => url.pathname.startsWith(`/${path}`)) + ) { + return false + } + + return true +} + +// image + +export type ProcessImageUrlFn = (href: string) => string + +export const createImage = function (processImageUrl: ProcessImageUrlFn): RendererApi['image'] { + return function (this: Renderer, { href, title, text }) { + const resolvedHref = processImageUrl(href) + const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' + const altAttr = text ? ` alt="${escapeHtml(text)}"` : '' + return `` + } +} + +// heading + +/** + * Lazy ATX heading extension for marked: allows headings without a space after `#`. + * + * Reimplements the behavior of markdown-it-lazy-headers + * (https://npmx.dev/package/markdown-it-lazy-headers), which is used by npm's own markdown renderer + * marky-markdown (https://npmx.dev/package/marky-markdown). + * + * CommonMark requires a space after # for ATX headings, but many READMEs in the npm registry omit + * this space. This extension allows marked to parse these headings the same way npm does. + * + * @param exemptIssuePr do not turn #{number} into a heading, treat it as an issue/pr instead + */ +export function createMarkedHeadingExtension(exemptIssuePr?: boolean): TokenizerObject['heading'] { + return function (src: string) { + // Only match headings where `#` is immediately followed by non-whitespace, non-`#` content. + // Normal headings (with space) return false to fall through to marked's default tokenizer. + const match = /^ {0,3}(#{1,6})([^\s#][^\n]*)(?:\n+|$)/.exec(src) + if (!match) return false + if (exemptIssuePr && /^#\d+\b/.test(match[0])) return false + + console.log({ match, test: /^#\d+\b/.test(match[0]), exemptIssuePr }) + + let text = match[2]!.trim() + + // Strip trailing # characters only if preceded by a space (CommonMark behavior). + // e.g., "#heading ##" โ†’ "heading", but "#heading#" stays as "heading#" + if (text.endsWith('#')) { + const stripped = text.replace(/#+$/, '') + if (!stripped || stripped.endsWith(' ')) { + text = stripped.trim() + } + } + + return { + type: 'heading' as const, + raw: match[0]!, + depth: match[1]!.length as number, + text, + tokens: this.lexer.inline(text), + } + } +} + +export const USER_CONTENT_PREFIX = 'user-content-' + +// README h1 always becomes h3 +// For deeper levels, ensure sequential order +// Don't allow jumping more than 1 level deeper than previous +export function calculateSemanticDepth( + depth: number, + lastSemanticLevel: number, + minSemanticLevel: number, +) { + if (depth === 1) return minSemanticLevel + 1 + const maxAllowed = Math.min(lastSemanticLevel + 1, 6) + return Math.min(depth + minSemanticLevel, maxAllowed) +} + +export function getHeadingPlainText(text: string): string { + return decodeHtmlEntities(stripHtmlTags(text).trim()) +} + +export function getHeadingSlugSource(text: string): string { + return stripHtmlTags(text).trim() +} + +const htmlAnchorRe = /]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi + +export type ToUserContentIdFn = (id: string) => string + +type ProcessHeadingFn = ( + depth: number, + displayHtml: string, + plainText: string, + slugSource: string, + preservedAttrs?: string, +) => string + +export function createHeading(options: { + lastSemanticLevel?: number + toUserContentId: ToUserContentIdFn +}) { + let { lastSemanticLevel = 2, toUserContentId } = options + const minSemanticLevel = lastSemanticLevel + const toc: TocItem[] = [] + const usedSlugs = new Map() + + const heading: RendererApi['heading'] = function ( + this: Renderer, + { tokens, depth }, + ) { + const displayHtml = this.parser.parseInline(tokens) + const plainText = getHeadingPlainText(displayHtml) + const slugSource = getHeadingSlugSource(displayHtml) + return processHeading(depth, displayHtml, plainText, slugSource) + } + + const processHeading: ProcessHeadingFn = function ( + depth: number, + displayHtml: string, + plainText: string, + slugSource: string, + preservedAttrs = '', + ) { + const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel, minSemanticLevel) + lastSemanticLevel = semanticLevel + + let slug = slugify(slugSource) + if (!slug) slug = 'heading' + + const count = usedSlugs.get(slug) ?? 0 + usedSlugs.set(slug, count + 1) + const uniqueSlug = count === 0 ? slug : `${slug}-${count}` + const id = toUserContentId(uniqueSlug) + + if (plainText) { + toc.push({ text: plainText, id, depth }) + } + + // The browser doesn't support anchors within anchors and automatically extracts them from each other, + // causing a hydration error. To prevent this from happening in such cases, we use the anchor separately + if (displayHtml.match(htmlAnchorRe)?.length) { + return `${displayHtml}\n` + } + + return `${displayHtml}\n` + } + + return { heading, toc, processHeading } +} + +// html + +// Extract and preserve allowed attributes from HTML heading tags +export function extractHeadingAttrs(attrsString: string): string { + if (!attrsString) return '' + const preserved: string[] = [] + const alignMatch = /\balign=(["']?)([^"'\s>]+)\1/i.exec(attrsString) + if (alignMatch?.[2]) { + preserved.push(`align="${alignMatch[2]}"`) + } + return preserved.length > 0 ? ` ${preserved.join(' ')}` : '' +} + +// Intercept HTML headings so they get id, TOC entry, and correct semantic level. +// Also intercept raw HTML tags so playground links are collected in the same pass. +const htmlHeadingRe = /]*)?>([\s\S]*?)<\/h\1>/gi + +function normalizePreservedAnchorAttrs(attrs: string): string { + const cleanedAttrs = attrs + .replace(/\s+href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s+rel\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s+target\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .trim() + + return cleanedAttrs ? ` ${cleanedAttrs}` : '' +} + +export function createHtml({ + processHeading, + processLink, +}: { + processHeading: ProcessHeadingFn + processLink: ProcessLinkFn +}) { + // function withUserContentPrefix(value: string): string { + // return value.startsWith(USER_CONTENT_PREFIX) ? value : `${USER_CONTENT_PREFIX}${value}` + // } + + return function ({ text }: Tokens.HTML) { + let result = text.replace(htmlHeadingRe, (_, level, attrs = '', inner) => { + const depth = parseInt(level) + const plainText = getHeadingPlainText(inner) + const slugSource = getHeadingSlugSource(inner) + const preservedAttrs = extractHeadingAttrs(attrs) + return processHeading(depth, inner, plainText, slugSource, preservedAttrs).trimEnd() + }) + // Process raw HTML tags for playground link collection and URL resolution + result = result.replace(htmlAnchorRe, (_full, beforeHref, _quote, href, afterHref, inner) => { + const label = decodeHtmlEntities(stripHtmlTags(inner).trim()) + const { resolvedHref, extraAttrs } = processLink(href, label) + const preservedAttrs = normalizePreservedAnchorAttrs(`${beforeHref ?? ''}${afterHref ?? ''}`) + return `${inner}` + }) + return result + } +} + +// html rendering + +export function renderToRawHtml({ + renderer, + markdownBody, + frontmatterHtml = '', + markedInstance, +}: { + renderer: Renderer + markdownBody: string + frontmatterHtml?: string + markedInstance?: Marked +}) { + // Strip trailing whitespace (tabs/spaces) from code block closing fences. + // While marky-markdown handles these gracefully, marked fails to recognize + // the end of a code block if the closing fences are followed by unexpected whitespaces. + const normalizedContent = markdownBody.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') + return ( + frontmatterHtml + + ((markedInstance ?? marked).parse(normalizedContent, { + renderer, + }) as string) + ) +} + +/// sanatizer + +export const ALLOWED_ATTR: Record = { + '*': ['id'], // Allow id on all tags + 'a': ['href', 'title', 'target', 'rel', 'tabindex', 'aria-hidden'], + 'img': ['src', 'alt', 'title', 'width', 'height', 'align'], + 'source': ['src', 'srcset', 'type', 'media'], + 'button': ['class', 'title', 'type', 'aria-label', 'data-copy'], + 'th': ['colspan', 'rowspan', 'align', 'valign', 'width'], + 'td': ['colspan', 'rowspan', 'align', 'valign', 'width'], + 'h2': ['data-level', 'align'], + 'h3': ['data-level', 'align'], + 'h4': ['data-level', 'align'], + 'h5': ['data-level', 'align'], + 'h6': ['data-level', 'align'], + 'blockquote': ['data-callout'], + 'details': ['open'], + 'code': ['class'], + 'pre': ['class'], + 'span': ['class', 'style'], + 'div': ['class', 'align'], + 'p': ['align'], +} + +// allow h1-h6, but replace h1-h2 later since we shift README headings down by 2 levels +// (page h1 = package name, h2 = "Readme" section, so README h1 โ†’ h3) +export const ALLOWED_TAGS = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'hr', + 'ul', + 'ol', + 'li', + 'blockquote', + 'pre', + 'code', + 'a', + 'strong', + 'em', + 'del', + 's', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'img', + 'picture', + 'source', + 'details', + 'summary', + 'div', + 'span', + 'sup', + 'sub', + 'kbd', + 'mark', + 'button', + 'dl', + 'dt', + 'dd', +] + +export function sanitizeRawHTML( + rawHtml: string, + { + processImageUrl, + processLink, + toUserContentId, + lastSemanticLevel = 2, + }: { + processImageUrl: ProcessImageUrlFn + processLink: ProcessLinkFn + toUserContentId: ToUserContentIdFn + lastSemanticLevel?: number + }, +) { + // Helper to prefix id attributes with 'user-content-' + function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) { + if (attribs.id) { + attribs.id = attribs.id.startsWith(USER_CONTENT_PREFIX) + ? attribs.id + : toUserContentId(attribs.id) + } + return { tagName, attribs } + } + + const h1 = `h${lastSemanticLevel + 1}`, + h2 = `h${lastSemanticLevel + 2}`, + h3 = `h${lastSemanticLevel + 3}`, + h4 = `h${lastSemanticLevel + 4}` + + return sanitizeHtml(rawHtml, { + allowedTags: ALLOWED_TAGS, + allowedAttributes: ALLOWED_ATTR, + allowedSchemes: ['http', 'https', 'mailto'], + // disallow styles other than the ones shiki emits + allowedStyles: { + span: { + 'color': [/^#[0-9a-f]{3,8}$/i], + '--shiki-light': [/^#[0-9a-f]{3,8}$/i], + }, + }, + transformTags: { + // Headings are already processed to correct semantic levels by processHeading() + // during the marked rendering pass. The sanitizer just needs to preserve them. + // For any stray headings that didn't go through processHeading (shouldn't happen), + // we still apply a safe fallback shift. + h1: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h1', attribs } + return { tagName: h1, attribs: { ...attribs, 'data-level': '1' } } + }, + h2: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h2', attribs } + return { tagName: h2, attribs: { ...attribs, 'data-level': '2' } } + }, + h3: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h3', attribs } + return { tagName: h3, attribs: { ...attribs, 'data-level': '3' } } + }, + h4: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h4', attribs } + return { tagName: h4, attribs: { ...attribs, 'data-level': '4' } } + }, + h5: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h5', attribs } + return { tagName: 'h6', attribs: { ...attribs, 'data-level': '5' } } + }, + h6: (_, attribs) => { + if (attribs['data-level']) return { tagName: 'h6', attribs } + return { tagName: 'h6', attribs: { ...attribs, 'data-level': '6' } } + }, + img: (tagName, attribs) => { + if (attribs.src) { + attribs.src = processImageUrl(attribs.src) + } + return { tagName, attribs } + }, + source: (tagName, attribs) => { + if (attribs.src) { + attribs.src = processImageUrl(attribs.src) + } + if (attribs.srcset) { + attribs.srcset = attribs.srcset + .split(',') + .map(entry => { + const parts = entry.trim().split(/\s+/) + const url = parts[0] + if (!url) return entry.trim() + const descriptor = parts[1] + const resolvedUrl = processImageUrl(url) + return descriptor ? `${resolvedUrl} ${descriptor}` : resolvedUrl + }) + .join(', ') + } + return { tagName, attribs } + }, + // Markdown links are fully processed in renderer.link (single-pass). + // However, inline HTML tags inside paragraphs are NOT seen by + // renderer.html (marked parses them as paragraph tokens, not html tokens). + // So we still need to collect playground links here for those cases via processUrl from readme. + // The seenUrls set ensures no duplicates across both paths. + a: (tagName, attribs) => { + if (!attribs.href) { + return { tagName, attribs } + } + + let { resolvedHref } = processLink(attribs.href, '') + + // for changelog all routes are orianted around the git provider, prefixing with $ set it to npmx + if (resolvedHref.startsWith('$')) { + resolvedHref = resolvedHref.replace('$', '') + } + + // Add security attributes for external links (idempotent) + if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { + attribs.rel = 'nofollow noreferrer noopener' + attribs.target = '_blank' + } + attribs.href = resolvedHref + return { tagName, attribs } + }, + + div: prefixId, + p: prefixId, + span: prefixId, + section: prefixId, + article: prefixId, + }, + }) +} diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 6e220f7827..717136f981 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -1,14 +1,26 @@ -import type { ReadmeResponse, TocItem } from '#shared/types/readme' -import type { Tokens } from 'marked' +import type { ReadmeResponse } from '#shared/types/readme' +import { + type ProcessLinkFn, + blockquote, + createCodeHighlighter, + isNpmJsUrlThatCanBeRedirected, + createLink, + createHeading, + createHtml, + USER_CONTENT_PREFIX, + createMarkedHeadingExtension, + renderToRawHtml, + createImage, + sanitizeRawHTML, + decodeHashFragment, +} from './mdKit' import matter from 'gray-matter' import { marked } from 'marked' -import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' -import { decodeHtmlEntities, stripHtmlTags, slugify } from '#shared/utils/html' +import { decodeHtmlEntities, slugify } from '#shared/utils/html' import { convertToEmoji } from '#shared/utils/emoji' import { toProxiedImageUrl } from '#server/utils/image-proxy' -import { highlightCodeSync } from './shiki' import { escapeHtml } from './docs/text' /** @@ -139,137 +151,12 @@ function matchPlaygroundProvider(url: string): PlaygroundProvider | null { return null } -// allow h1-h6, but replace h1-h2 later since we shift README headings down by 2 levels -// (page h1 = package name, h2 = "Readme" section, so README h1 โ†’ h3) -export const ALLOWED_TAGS = [ - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'p', - 'br', - 'hr', - 'ul', - 'ol', - 'li', - 'blockquote', - 'pre', - 'code', - 'a', - 'strong', - 'em', - 'del', - 's', - 'table', - 'thead', - 'tbody', - 'tr', - 'th', - 'td', - 'img', - 'picture', - 'source', - 'details', - 'summary', - 'div', - 'span', - 'sup', - 'sub', - 'kbd', - 'mark', - 'button', - 'dl', - 'dt', - 'dd', -] - -export const ALLOWED_ATTR: Record = { - '*': ['id'], // Allow id on all tags - 'a': ['href', 'title', 'target', 'rel'], - 'img': ['src', 'alt', 'title', 'width', 'height', 'align'], - 'source': ['src', 'srcset', 'type', 'media'], - 'button': ['class', 'title', 'type', 'aria-label', 'data-copy'], - 'th': ['colspan', 'rowspan', 'align', 'valign', 'width'], - 'td': ['colspan', 'rowspan', 'align', 'valign', 'width'], - 'h3': ['data-level', 'align'], - 'h4': ['data-level', 'align'], - 'h5': ['data-level', 'align'], - 'h6': ['data-level', 'align'], - 'blockquote': ['data-callout'], - 'details': ['open'], - 'code': ['class'], - 'pre': ['class'], - 'span': ['class', 'style'], - 'div': ['class', 'align'], - 'p': ['align'], -} - -function getHeadingPlainText(text: string): string { - return decodeHtmlEntities(stripHtmlTags(text).trim()) -} - -function getHeadingSlugSource(text: string): string { - return stripHtmlTags(text).trim() -} - -/** - * Lazy ATX heading extension for marked: allows headings without a space after `#`. - * - * Reimplements the behavior of markdown-it-lazy-headers - * (https://npmx.dev/package/markdown-it-lazy-headers), which is used by npm's own markdown renderer - * marky-markdown (https://npmx.dev/package/marky-markdown). - * - * CommonMark requires a space after # for ATX headings, but many READMEs in the npm registry omit - * this space. This extension allows marked to parse these headings the same way npm does. - */ marked.use({ tokenizer: { - heading(src: string) { - // Only match headings where `#` is immediately followed by non-whitespace, non-`#` content. - // Normal headings (with space) return false to fall through to marked's default tokenizer. - const match = /^ {0,3}(#{1,6})([^\s#][^\n]*)(?:\n+|$)/.exec(src) - if (!match) return false - - let text = match[2]!.trim() - - // Strip trailing # characters only if preceded by a space (CommonMark behavior). - // e.g., "#heading ##" โ†’ "heading", but "#heading#" stays as "heading#" - if (text.endsWith('#')) { - const stripped = text.replace(/#+$/, '') - if (!stripped || stripped.endsWith(' ')) { - text = stripped.trim() - } - } - - return { - type: 'heading' as const, - raw: match[0]!, - depth: match[1]!.length as number, - text, - tokens: this.lexer.inline(text), - } - }, + heading: createMarkedHeadingExtension(), }, }) -/** These path on npmjs.com don't belong to packages or search, so we shouldn't try to replace them with npmx.dev urls */ -const reservedPathsNpmJs = [ - 'products', - 'login', - 'signup', - 'advisories', - 'blog', - 'about', - 'press', - 'policies', -] - -const npmJsHosts = new Set(['www.npmjs.com', 'npmjs.com', 'www.npmjs.org', 'npmjs.org']) - -const USER_CONTENT_PREFIX = 'user-content-' - function withUserContentPrefix(value: string): string { return value.startsWith(USER_CONTENT_PREFIX) ? value : `${USER_CONTENT_PREFIX}${value}` } @@ -282,39 +169,6 @@ function toUserContentHash(value: string): string { return `#${withUserContentPrefix(value)}` } -function decodeHashFragment(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function normalizePreservedAnchorAttrs(attrs: string): string { - const cleanedAttrs = attrs - .replace(/\s+href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') - .replace(/\s+rel\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') - .replace(/\s+target\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') - .trim() - - return cleanedAttrs ? ` ${cleanedAttrs}` : '' -} - -export const isNpmJsUrlThatCanBeRedirected = (url: URL) => { - if (!npmJsHosts.has(url.host)) { - return false - } - - if ( - url.pathname === '/' || - reservedPathsNpmJs.some(path => url.pathname.startsWith(`/${path}`)) - ) { - return false - } - - return true -} - /** * Resolve a relative URL to an absolute URL. * If repository info is available, resolve to provider's raw file URLs. @@ -426,24 +280,6 @@ function resolveImageUrl(url: string, packageName: string, repoInfo?: Repository return toProxiedImageUrl(rawUrl, imageProxySecret) } -// Helper to prefix id attributes with 'user-content-' - -export function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) { - if (attribs.id) { - attribs.id = withUserContentPrefix(attribs.id) - } - return { tagName, attribs } -} - -// README h1 always becomes h3 -// For deeper levels, ensure sequential order -// Don't allow jumping more than 1 level deeper than previous -export function calculateSemanticDepth(depth: number, lastSemanticLevel: number) { - if (depth === 1) return 3 - const maxAllowed = Math.min(lastSemanticLevel + 1, 6) - return Math.min(depth + 2, maxAllowed) -} - /** * Render YAML frontmatter as a GitHub-style key-value table. */ @@ -461,17 +297,6 @@ function renderFrontmatterTable(data: Record): string { return `\n${rows}\n
KeyValue
\n` } -// Extract and preserve allowed attributes from HTML heading tags -function extractHeadingAttrs(attrsString: string): string { - if (!attrsString) return '' - const preserved: string[] = [] - const alignMatch = /\balign=(["']?)([^"'\s>]+)\1/i.exec(attrsString) - if (alignMatch?.[2]) { - preserved.push(`align="${alignMatch[2]}"`) - } - return preserved.length > 0 ? ` ${preserved.join(' ')}` : '' -} - export async function renderReadmeHtml( content: string, packageName: string, @@ -492,117 +317,29 @@ export async function renderReadmeHtml( // If frontmatter parsing fails, render the full content as-is } - const shiki = await getShikiHighlighter() const renderer = new marked.Renderer() // Collect playground links during parsing const collectedLinks: PlaygroundLink[] = [] const seenUrls = new Set() - // Collect table of contents items during parsing - const toc: TocItem[] = [] - - // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2) - const usedSlugs = new Map() - - // Track heading hierarchy to ensure sequential order for accessibility - // Page h1 = package name, h2 = "Readme" section heading - // So README starts at h3, and we ensure no levels are skipped - // Visual styling preserved via data-level attribute (original depth) - let lastSemanticLevel = 2 // Start after h2 (the "Readme" section heading) - - // Shared heading processing for both markdown and HTML headings - function processHeading( - depth: number, - displayHtml: string, - plainText: string, - slugSource: string, - preservedAttrs = '', - ) { - const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel) - lastSemanticLevel = semanticLevel - - let slug = slugify(slugSource) - if (!slug) slug = 'heading' - - const count = usedSlugs.get(slug) ?? 0 - usedSlugs.set(slug, count + 1) - const uniqueSlug = count === 0 ? slug : `${slug}-${count}` - const id = toUserContentId(uniqueSlug) - - if (plainText) { - toc.push({ text: plainText, id, depth }) - } - - // The browser doesn't support anchors within anchors and automatically extracts them from each other, - // causing a hydration error. To prevent this from happening in such cases, we use the anchor separately - if (htmlAnchorRe.test(displayHtml)) { - return `${displayHtml}
\n` - } - - return `${displayHtml}\n` - } + const { toc, heading, processHeading } = createHeading({ toUserContentId }) - const anchorTokenRegex = /^$/ - renderer.heading = function ({ tokens, depth }: Tokens.Heading) { - const isAnchorHeading = - anchorTokenRegex.test(tokens[0]?.raw ?? '') && tokens[tokens.length - 1]?.raw === '' - - // for anchor headings, we will ignore user-added id and add our own - const tokensWithoutAnchor = isAnchorHeading ? tokens.slice(1, -1) : tokens - const displayHtml = this.parser.parseInline(tokensWithoutAnchor) - const plainText = getHeadingPlainText(displayHtml) - const slugSource = getHeadingSlugSource(displayHtml) - return processHeading(depth, displayHtml, plainText, slugSource) - } - - // Intercept HTML headings so they get id, TOC entry, and correct semantic level. - // Also intercept raw HTML tags so playground links are collected in the same pass. - const htmlHeadingRe = /]*)?>([\s\S]*?)<\/h\1>/gi - const htmlAnchorRe = /]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi - renderer.html = function ({ text }: Tokens.HTML) { - let result = text.replace(htmlHeadingRe, (_, level, attrs = '', inner) => { - const depth = parseInt(level) - const plainText = getHeadingPlainText(inner) - const slugSource = getHeadingSlugSource(inner) - const preservedAttrs = extractHeadingAttrs(attrs) - return processHeading(depth, inner, plainText, slugSource, preservedAttrs).trimEnd() - }) - // Process raw HTML tags for playground link collection and URL resolution - result = result.replace(htmlAnchorRe, (_full, beforeHref, _quote, href, afterHref, inner) => { - const label = decodeHtmlEntities(stripHtmlTags(inner).trim()) - const { resolvedHref, extraAttrs } = processLink(href, label) - const preservedAttrs = normalizePreservedAnchorAttrs(`${beforeHref ?? ''}${afterHref ?? ''}`) - return `${inner}` - }) - return result - } + renderer.heading = heading // Syntax highlighting for code blocks (uses shared highlighter) - renderer.code = ({ text, lang }: Tokens.Code) => { - const html = highlightCodeSync(shiki, text, lang || 'text') - // Add copy button - return `
- -${html} -
` - } + renderer.code = await createCodeHighlighter() - // Resolve image URLs (with GitHub blob โ†’ raw conversion) - renderer.image = ({ href, title, text }: Tokens.Image) => { - const resolvedHref = resolveImageUrl(href, packageName, repoInfo) - const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' - const altAttr = text ? ` alt="${escapeHtml(text)}"` : '' - return `` + function processImageUrl(href: string) { + return resolveImageUrl(href, packageName, repoInfo) } + renderer.image = createImage(processImageUrl) + // Helper: resolve a link href, collect playground links, and build attributes. // Used by both the markdown renderer.link and the HTML interceptor so that // all link processing happens in a single pass during marked rendering. - function processLink(href: string, label: string): { resolvedHref: string; extraAttrs: string } { + const processLink: ProcessLinkFn = (href: string, label: string) => { const resolvedHref = resolveUrl(href, packageName, repoInfo) // Collect playground links @@ -618,7 +355,7 @@ ${html} } // Security attributes for external links - let extraAttrs = + const extraAttrs = resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true }) ? ' rel="nofollow noreferrer noopener" target="_blank"' : '' @@ -626,155 +363,18 @@ ${html} return { resolvedHref, extraAttrs } } - // Resolve link URLs, add security attributes, and collect playground links - // โ€” all in a single pass during marked rendering (no deferred processing) - renderer.link = function ({ href, title, tokens }: Tokens.Link) { - const text = this.parser.parseInline(tokens) - const titleAttr = title ? ` title="${title}"` : '' - let plainText = stripHtmlTags(text).trim() - - // If plain text is empty, check if we have an image with alt text - if (!plainText && tokens.length === 1 && tokens[0]?.type === 'image') { - plainText = tokens[0].text - } - - const { resolvedHref, extraAttrs } = processLink(href, plainText || title || '') - - if (!resolvedHref) return text - - return `${text}` - } + renderer.link = createLink(processLink) + renderer.html = createHtml({ processHeading, processLink }) // GitHub-style callouts: > [!NOTE], > [!TIP], etc. - renderer.blockquote = function ({ tokens }: Tokens.Blockquote) { - const body = this.parser.parse(tokens) + renderer.blockquote = blockquote - const calloutMatch = body.match(/^

\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:
)?\s*/i) - - if (calloutMatch?.[1]) { - const calloutType = calloutMatch[1].toLowerCase() - const cleanedBody = body.replace(calloutMatch[0], '

') - return `

${cleanedBody}
\n` - } - - return `
${body}
\n` - } + const rawHtml = renderToRawHtml({ renderer, markdownBody, frontmatterHtml }) - marked.setOptions({ renderer }) - - // Strip trailing whitespace (tabs/spaces) from code block closing fences. - // While marky-markdown handles these gracefully, marked fails to recognize - // the end of a code block if the closing fences are followed by unexpected whitespaces. - const normalizedContent = markdownBody.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') - const rawHtml = frontmatterHtml + (marked.parse(normalizedContent) as string) - - const sanitized = sanitizeHtml(rawHtml, { - allowedTags: ALLOWED_TAGS, - allowedAttributes: ALLOWED_ATTR, - allowedSchemes: ['http', 'https', 'mailto'], - // disallow styles other than the ones shiki emits - allowedStyles: { - span: { - 'color': [/^#[0-9a-f]{3,8}$/i], - '--shiki-light': [/^#[0-9a-f]{3,8}$/i], - }, - }, - // Transform img src URLs (GitHub blob โ†’ raw, relative โ†’ GitHub raw) - transformTags: { - // Headings are already processed to correct semantic levels by processHeading() - // during the marked rendering pass. The sanitizer just needs to preserve them. - // For any stray headings that didn't go through processHeading (shouldn't happen), - // we still apply a safe fallback shift. - h1: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h1', attribs } - return { tagName: 'h3', attribs: { ...attribs, 'data-level': '1' } } - }, - h2: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h2', attribs } - return { tagName: 'h4', attribs: { ...attribs, 'data-level': '2' } } - }, - h3: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h3', attribs } - return { tagName: 'h5', attribs: { ...attribs, 'data-level': '3' } } - }, - h4: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h4', attribs } - return { tagName: 'h6', attribs: { ...attribs, 'data-level': '4' } } - }, - h5: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h5', attribs } - return { tagName: 'h6', attribs: { ...attribs, 'data-level': '5' } } - }, - h6: (_, attribs) => { - if (attribs['data-level']) return { tagName: 'h6', attribs } - return { tagName: 'h6', attribs: { ...attribs, 'data-level': '6' } } - }, - img: (tagName, attribs) => { - if (attribs.src) { - attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) - } - return { tagName, attribs } - }, - source: (tagName, attribs) => { - if (attribs.src) { - attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) - } - if (attribs.srcset) { - attribs.srcset = attribs.srcset - .split(',') - .map(entry => { - const parts = entry.trim().split(/\s+/) - const url = parts[0] - if (!url) return entry.trim() - const descriptor = parts[1] - const resolvedUrl = resolveImageUrl(url, packageName, repoInfo) - return descriptor ? `${resolvedUrl} ${descriptor}` : resolvedUrl - }) - .join(', ') - } - return { tagName, attribs } - }, - // Markdown links are fully processed in renderer.link (single-pass). - // However, inline HTML tags inside paragraphs are NOT seen by - // renderer.html (marked parses them as paragraph tokens, not html tokens). - // So we still need to collect playground links here for those cases. - // The seenUrls set ensures no duplicates across both paths. - a: (tagName, attribs) => { - if (!attribs.href) { - return { tagName, attribs } - } - - const resolvedHref = resolveUrl(attribs.href, packageName, repoInfo) - - // Collect playground links from inline HTML tags that weren't - // caught by renderer.link or renderer.html - const provider = matchPlaygroundProvider(resolvedHref) - if (provider && !seenUrls.has(resolvedHref)) { - seenUrls.add(resolvedHref) - collectedLinks.push({ - url: resolvedHref, - provider: provider.id, - providerName: provider.name, - // sanitize-html transformTags doesn't provide element text content, - // so we fall back to the provider name for the label - label: provider.name, - }) - } - - // Add security attributes for external links (idempotent) - if (resolvedHref && hasProtocol(resolvedHref, { acceptRelative: true })) { - attribs.rel = 'nofollow noreferrer noopener' - attribs.target = '_blank' - } - attribs.href = resolvedHref - return { tagName, attribs } - }, - div: prefixId, - p: prefixId, - span: prefixId, - section: prefixId, - article: prefixId, - }, + const sanitized = sanitizeRawHTML(rawHtml, { + processImageUrl, + processLink, + toUserContentId, }) return { diff --git a/shared/schemas/noodle.ts b/shared/schemas/noodle.ts new file mode 100644 index 0000000000..7d8a45af07 --- /dev/null +++ b/shared/schemas/noodle.ts @@ -0,0 +1,39 @@ +import * as v from 'valibot' + +const NoodleAuthorSchema = v.object({ + name: v.string(), + blueskyHandle: v.optional(v.string()), +}) + +const NoodleReferenceSchema = v.object({ + label: v.optional(v.string()), + url: v.string(), +}) + +// Required: key, title, slug, date. Everything else is optional and the +// detail page only renders sections for fields that are filled in. +const NoodleSchema = v.object({ + key: v.string(), + title: v.string(), + slug: v.string(), + date: v.string(), + dateTo: v.optional(v.string()), + // IANA timezone name, or "auto" for the visitor's local time. + timezone: v.optional(v.string()), + // When true, the npmx tagline is hidden while this noodle is active. + tagline: v.optional(v.boolean()), + occasion: v.optional(v.string()), + description: v.optional(v.string()), + // Public paths to variant images (draft / alternate takes), used by the lens carousel. + variants: v.optional(v.array(v.string())), + prUrl: v.optional(v.string()), + authors: v.optional(v.array(NoodleAuthorSchema)), + // External links (Wikipedia, NASA mission page, etc.) shown in the detail right panel. + references: v.optional(v.array(NoodleReferenceSchema)), + // Public path to the OG-image hero asset. + posterImage: v.optional(v.string()), + // Optional image rendered behind `posterImage` (e.g. a backdrop the wordmark sits on). + posterBackdrop: v.optional(v.string()), +}) + +export type Noodle = v.InferOutput diff --git a/shared/types/changelog.ts b/shared/types/changelog.ts index 3ec410d08d..d3e5f5858b 100644 --- a/shared/types/changelog.ts +++ b/shared/types/changelog.ts @@ -32,4 +32,5 @@ export interface ReleaseData { id: string | number publishedAt?: string toc?: TocItem[] + link: string } diff --git a/shared/types/compare.ts b/shared/types/compare.ts index bce433b877..ca6a0cdf4b 100644 --- a/shared/types/compare.ts +++ b/shared/types/compare.ts @@ -147,8 +147,12 @@ export interface FileDiffResponse { } /** Metadata */ meta: { + /** Whether diff was rendered in large-file degraded mode */ + large?: boolean /** Whether diff was truncated */ truncated?: boolean + /** Why the diff was truncated */ + truncationReason?: 'too_many_lines' /** Time taken to compute (ms) */ computeTime?: number } diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts index c7bfafdc70..bedcda8d33 100644 --- a/shared/types/comparison.ts +++ b/shared/types/comparison.ts @@ -18,6 +18,7 @@ export type ComparisonFacet = | 'deprecated' | 'totalLikes' | 'githubStars' + | 'githubForks' | 'githubIssues' | 'createdAt' @@ -62,6 +63,9 @@ export const FACET_INFO: Record> = { githubStars: { category: 'health', }, + githubForks: { + category: 'health', + }, githubIssues: { category: 'health', }, diff --git a/shared/utils/diff.ts b/shared/utils/diff.ts index aa8541e12f..a9a4c68627 100644 --- a/shared/utils/diff.ts +++ b/shared/utils/diff.ts @@ -342,6 +342,39 @@ export function insertSkipBlocks(hunks: DiffHunk[]): (DiffHunk | DiffSkipBlock)[ return result } +export function truncateDiffHunks( + hunks: (DiffHunk | DiffSkipBlock)[], + maxLines: number, +): { hunks: (DiffHunk | DiffSkipBlock)[]; truncated: boolean } { + const result: (DiffHunk | DiffSkipBlock)[] = [] + let remainingLines = maxLines + + for (const hunk of hunks) { + if (remainingLines <= 0) { + return { hunks: result, truncated: true } + } + + if (hunk.type === 'skip') { + result.push(hunk) + continue + } + + if (hunk.lines.length <= remainingLines) { + result.push(hunk) + remainingLines -= hunk.lines.length + continue + } + + result.push({ + ...hunk, + lines: hunk.lines.slice(0, remainingLines), + }) + return { hunks: result, truncated: true } + } + + return { hunks: result, truncated: false } +} + export function createDiff( oldContent: string, newContent: string, diff --git a/shared/utils/download-chart-last-label.ts b/shared/utils/download-chart-last-label.ts new file mode 100644 index 0000000000..16ad6b15ae --- /dev/null +++ b/shared/utils/download-chart-last-label.ts @@ -0,0 +1,182 @@ +/** + * Utility to be used in vue-data-ui line charts (`VueUiXy`) using the `#svg` slot to display the last value as data label. + * In case of mutliple series, if label collisions are detected, labels are distributed as close as possible to their related datapoint, shifted to avoid overlaps, and linked to the last datapoint with an elbowed marker + */ +export function createLastDatapointLabelsSvg({ + series, + drawingArea, + colors, + formatValue, + isDarkMode, + fontSize = 20, + labelOffset = 24, + labelHeight = 30, +}: { + series: LastDatapointLabelSerie[] + drawingArea: { + top: number + height?: number + bottom?: number + } + colors: LastDatapointLabelColors + formatValue: (value: number) => string + isDarkMode: boolean + svgWidth?: number + fontSize?: number + labelOffset?: number + labelHeight?: number +}) { + const drawingAreaTop = Number(drawingArea.top ?? 0) + const drawingAreaHeight = Number( + drawingArea.height ?? Number(drawingArea.bottom ?? 0) - drawingAreaTop, + ) + const drawingAreaBottom = drawingAreaTop + drawingAreaHeight + + const labels = series + .map(serie => { + const lastPlot = Array.isArray(serie.plots) ? serie.plots.at(-1) : null + if (!lastPlot) return null + + const value = Number(lastPlot.value ?? 0) + const safeValue = Number.isFinite(value) ? value : 0 + const text = formatValue(safeValue) + + return { + x: Number(lastPlot.x ?? 0), + y: Number(lastPlot.y ?? 0), + value: safeValue, + color: String(serie.color ?? colors.fallbackSerieColor), + text, + width: text.length * fontSize * 0.58, + } + }) + .filter(isLastDatapointLabel) + + if (!labels.length) return '' + + const hasCollision = labels.some((label, labelIndex) => + labels.some((otherLabel, otherLabelIndex) => { + if (labelIndex === otherLabelIndex) return false + + return ( + label.x + labelOffset < otherLabel.x + labelOffset + otherLabel.width && + label.x + labelOffset + label.width > otherLabel.x + labelOffset && + label.y - labelHeight / 2 < otherLabel.y + labelHeight / 2 && + label.y + labelHeight / 2 > otherLabel.y - labelHeight / 2 + ) + }), + ) + + if (!hasCollision) { + return labels + .map( + label => ` + + ${label.text} + + `, + ) + .join('\n') + } + + const sortedLabels = [...labels].sort((firstLabel, secondLabel) => { + return firstLabel.y - secondLabel.y + }) + + const minimumLabelY = drawingAreaTop + labelHeight / 2 + const maximumLabelY = drawingAreaBottom - labelHeight / 2 + + const positionedLabels = sortedLabels.map(label => ({ + ...label, + labelY: Math.min(maximumLabelY, Math.max(minimumLabelY, label.y)), + })) + + for (let index = 1; index < positionedLabels.length; index += 1) { + const previousLabel = positionedLabels[index - 1] + const currentLabel = positionedLabels[index] + if (!previousLabel || !currentLabel) continue + if (currentLabel.labelY - previousLabel.labelY < labelHeight) { + currentLabel.labelY = previousLabel.labelY + labelHeight + } + } + + const lastLabel = positionedLabels.at(-1) + if (!lastLabel) return '' + const overflow = lastLabel.labelY - maximumLabelY + + if (overflow > 0) { + for (const label of positionedLabels) { + label.labelY -= overflow + } + } + + const labelX = Math.max(...positionedLabels.map(label => label.x)) + labelOffset + 10 + + return positionedLabels + .map(label => { + const connectorStartX = label.x + 5 + const connectorEndX = labelX + + return ` + + + ${label.text} + + ` + }) + .join('\n') +} + +export type LastDatapointLabelColors = { + foreground: string + background: string + fallbackSerieColor: string +} + +export type LastDatapointLabelSerie = { + color?: string + plots?: { + x?: number | null + y?: number | null + value?: number | null + }[] +} + +type LastDatapointLabel = { + x: number + y: number + value: number + color: string + text: string + width: number +} + +function isLastDatapointLabel(label: LastDatapointLabel | null): label is LastDatapointLabel { + return label !== null +} diff --git a/shared/utils/download-ranges.ts b/shared/utils/download-ranges.ts new file mode 100644 index 0000000000..c555493a9c --- /dev/null +++ b/shared/utils/download-ranges.ts @@ -0,0 +1,52 @@ +import type { DailyRawPoint } from '~/types/chart' +import { addDays, parseIsoDate, toIsoDate } from '~/utils/date' + +export function differenceInUtcDaysInclusive(startIso: string, endIso: string): number { + const start = parseIsoDate(startIso) + const end = parseIsoDate(endIso) + + return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 +} + +export function splitIsoRangeIntoChunksInclusive( + startIso: string, + endIso: string, + maximumDaysPerRequest: number, +): Array<{ startIso: string; endIso: string }> { + const totalDays = differenceInUtcDaysInclusive(startIso, endIso) + + if (totalDays <= maximumDaysPerRequest) { + return [{ startIso, endIso }] + } + + const chunks: Array<{ startIso: string; endIso: string }> = [] + + let cursorStart = parseIsoDate(startIso) + const finalEnd = parseIsoDate(endIso) + + while (cursorStart.getTime() <= finalEnd.getTime()) { + const cursorEnd = addDays(cursorStart, maximumDaysPerRequest - 1) + const actualEnd = cursorEnd.getTime() < finalEnd.getTime() ? cursorEnd : finalEnd + + chunks.push({ + startIso: toIsoDate(cursorStart), + endIso: toIsoDate(actualEnd), + }) + + cursorStart = addDays(actualEnd, 1) + } + + return chunks +} + +export function mergeDailyPoints(points: DailyRawPoint[]): DailyRawPoint[] { + const valuesByDay = new Map() + + for (const point of points) { + valuesByDay.set(point.day, (valuesByDay.get(point.day) ?? 0) + point.value) + } + + return Array.from(valuesByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, value]) => ({ day, value })) +} diff --git a/shared/utils/embed-chart-colors.ts b/shared/utils/embed-chart-colors.ts new file mode 100644 index 0000000000..990c617da6 --- /dev/null +++ b/shared/utils/embed-chart-colors.ts @@ -0,0 +1,37 @@ +type EmbedTheme = 'light' | 'dark' + +export function resolveEmbedChartColors(theme: EmbedTheme = 'light') { + // duplicated from main.css + const palettes = { + dark: { + default: { + bg: 'oklch(0.171 0 0)', + bgSubtle: 'oklch(0.198 0 0)', + bgMuted: 'oklch(0.236 0 0)', + bgElevated: 'oklch(0.266 0 0)', + fg: 'oklch(0.982 0 0)', + fgMuted: 'oklch(0.749 0 0)', + fgSubtle: 'oklch(0.673 0 0)', + border: 'oklch(0.269 0 0)', + accent: 'oklch(0.787 0.128 230.318)', + }, + }, + light: { + default: { + bg: 'oklch(1 0 0)', + bgSubtle: 'oklch(0.979 0.001 286.375)', + bgMuted: 'oklch(0.955 0.001 286.76)', + bgElevated: 'oklch(0.94 0.002 287.29)', + fg: 'oklch(0.146 0 0)', + fgMuted: 'oklch(0.398 0 0)', + fgSubtle: 'oklch(0.48 0 0)', + border: 'oklch(0.8514 0 0)', + accent: 'oklch(0.5 0.16 247.27)', + }, + }, + } as const + + return { + ...palettes[theme].default, + } +} diff --git a/shared/utils/parse-package-param.ts b/shared/utils/parse-package-param.ts index 310fcd2fd0..7ea86c73b8 100644 --- a/shared/utils/parse-package-param.ts +++ b/shared/utils/parse-package-param.ts @@ -58,3 +58,50 @@ export function parsePackageParam(pkgParam: string): ParsedPackageParams { rest: [], } } + +/** + * Parse a package spec into its name and optional version. + * Handles formats like: `name`, `name@version`, `@scope/name`, `@scope/name@version`. + * + * The version segment may be an exact version or a dist-tag (e.g. `next`); it is + * returned verbatim for the caller to resolve. + * + * @example + * ```ts + * parsePackageSpec('react') // { name: 'react' } + * parsePackageSpec('react@18.2.0') // { name: 'react', version: '18.2.0' } + * parsePackageSpec('@vue/reactivity') // { name: '@vue/reactivity' } + * parsePackageSpec('@vue/reactivity@3.4.0') // { name: '@vue/reactivity', version: '3.4.0' } + * ``` + */ +export function parsePackageSpec(input: string): { name: string; version?: string } { + if (input.startsWith('@')) { + // Scoped package: @scope/name or @scope/name@version + const slashIndex = input.indexOf('/') + if (slashIndex === -1) { + // Invalid format like just "@scope" + return { name: input } + } + const afterSlash = input.slice(slashIndex + 1) + const atIndex = afterSlash.indexOf('@') + if (atIndex === -1) { + // @scope/name (no version) + return { name: input } + } + // @scope/name@version + return { + name: input.slice(0, slashIndex + 1 + atIndex), + version: afterSlash.slice(atIndex + 1), + } + } + + // Unscoped package: name or name@version + const atIndex = input.indexOf('@') + if (atIndex === -1) { + return { name: input } + } + return { + name: input.slice(0, atIndex), + version: input.slice(atIndex + 1), + } +} diff --git a/shared/utils/trends-chart.ts b/shared/utils/trends-chart.ts new file mode 100644 index 0000000000..0033a4cdba --- /dev/null +++ b/shared/utils/trends-chart.ts @@ -0,0 +1,709 @@ +// Shared utilies for client & embed versions of the downloads trend chart + +import type { TextAlign, Theme as VueDataUiTheme } from 'vue-data-ui' +import type { VueUiXyConfig, VueUiXyDatasetItem } from 'vue-data-ui/vue-ui-xy' +import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '~/utils/colors' +import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' +import { applyEllipsis } from '~/utils/charts' +import { applyDataPipeline, DEFAULT_PREDICTION_POINTS } from '~/utils/chart-data-prediction' +import type { + ChartTimeGranularity, + DailyDataPoint, + EvolutionData, + MonthlyDataPoint, + WeeklyDataPoint, + YearlyDataPoint, +} from '~/types/chart' + +type TrendMetricId = 'downloads' | 'likes' | 'contributors' + +type TrendColors = Record + +type TrendFormatter = { + format: (value: number) => string +} + +type TranslateFn = (key: string, params?: Record) => string + +type TrendChartBaseOptions = { + packageNames: string[] + isMultiPackageMode: boolean + selectedMetric: TrendMetricId + selectedMetricLabel: string + selectedGranularity: ChartTimeGranularity + displayedGranularity: ChartTimeGranularity + singleEvolution: EvolutionData + evolutionsByPackage?: Record + effectivePackageNamesForMetric?: string[] + colors: TrendColors + accent?: string + isDarkMode: boolean + chartFilter: { + averageWindow: number + smoothingTau: number + predictionPoints?: number + } + useAnomalyCorrection?: boolean + applyAnomalyCorrection?: (params: { + data: EvolutionData + packageName: string + granularity: ChartTimeGranularity + }) => EvolutionData + t: TranslateFn + compactNumberFormatter: Intl.NumberFormat +} + +type TrendChartDataOptions = TrendChartBaseOptions + +type TrendChartConfigOptions = TrendChartBaseOptions & { + dates: number[] + isMobile: boolean + pending: boolean + locale: string + chartHeight: number + inModal?: boolean + tooltipPosition?: string +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isWeeklyDataset(data: unknown): data is WeeklyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'weekStart' in data[0] && + 'weekEnd' in data[0] && + 'value' in data[0] + ) +} + +export function isDailyDataset(data: unknown): data is DailyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'day' in data[0] && + 'value' in data[0] + ) +} + +export function isMonthlyDataset(data: unknown): data is MonthlyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'month' in data[0] && + 'value' in data[0] + ) +} + +export function isYearlyDataset(data: unknown): data is YearlyDataPoint[] { + return ( + Array.isArray(data) && + data.length > 0 && + isRecord(data[0]) && + 'year' in data[0] && + 'value' in data[0] + ) +} + +function extractSeriesPoints( + granularity: ChartTimeGranularity, + dataset: EvolutionData, +): Array<{ timestamp: number; value: number; hasAnomaly: boolean }> { + if (granularity === 'weekly' && isWeeklyDataset(dataset)) { + return dataset.map(item => ({ + timestamp: item.timestampEnd, + value: item.value, + hasAnomaly: !!item.hasAnomaly, + })) + } + + if ( + (granularity === 'daily' && isDailyDataset(dataset)) || + (granularity === 'monthly' && isMonthlyDataset(dataset)) || + (granularity === 'yearly' && isYearlyDataset(dataset)) + ) { + return (dataset as Array<{ timestamp: number; value: number; hasAnomaly?: boolean }>).map( + item => ({ + timestamp: item.timestamp, + value: item.value, + hasAnomaly: !!item.hasAnomaly, + }), + ) + } + + return [] +} + +function formatSingleXyDataset(options: { + granularity: ChartTimeGranularity + dataset: EvolutionData + seriesName: string + accent: string + isDarkMode: boolean +}): { dataset: VueUiXyDatasetItem[] | null; dates: number[] } { + const lightColor = options.isDarkMode ? lightenOklch(options.accent, 0.618) : undefined + const temperatureColors = lightColor ? [lightColor, options.accent] : undefined + + const datasetItem: VueUiXyDatasetItem = { + name: applyEllipsis(options.seriesName, 32), + type: 'line', + series: options.dataset.map(item => item.value), + color: options.accent, + temperatureColors, + useArea: true, + dashIndices: options.dataset + .map((item, index) => (item.hasAnomaly ? index : -1)) + .filter(index => index !== -1), + } + + if (options.granularity === 'weekly' && isWeeklyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestampEnd), + } + } + + if (options.granularity === 'daily' && isDailyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + if (options.granularity === 'monthly' && isMonthlyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + if (options.granularity === 'yearly' && isYearlyDataset(options.dataset)) { + return { + dataset: [datasetItem], + dates: options.dataset.map(item => item.timestamp), + } + } + + return { dataset: null, dates: [] } +} + +export function buildTrendsChartData(options: TrendChartDataOptions): { + dataset: VueUiXyDatasetItem[] | null + dates: number[] +} { + const accent = options.accent ?? options.colors.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK + + if (!options.isMultiPackageMode) { + const packageName = options.packageNames[0] ?? '' + return formatSingleXyDataset({ + granularity: options.displayedGranularity, + dataset: options.singleEvolution, + seriesName: packageName, + accent, + isDarkMode: options.isDarkMode, + }) + } + + const names = options.effectivePackageNamesForMetric ?? options.packageNames + const timestampSet = new Set() + const pointsByPackage = new Map< + string, + Array<{ timestamp: number; value: number; hasAnomaly?: boolean }> + >() + + for (const packageName of names) { + let data = options.evolutionsByPackage?.[packageName] ?? [] + + if ( + options.selectedMetric === 'downloads' && + options.useAnomalyCorrection && + options.applyAnomalyCorrection + ) { + data = options.applyAnomalyCorrection({ + data, + packageName, + granularity: options.displayedGranularity, + }) + } + + const points = extractSeriesPoints(options.displayedGranularity, data) + pointsByPackage.set(packageName, points) + + for (const point of points) { + timestampSet.add(point.timestamp) + } + } + + const dates = Array.from(timestampSet).sort((a, b) => a - b) + + if (!dates.length) { + return { dataset: null, dates: [] } + } + + const dataset = names.map(packageName => { + const points = pointsByPackage.get(packageName) ?? [] + const valueByTimestamp = new Map() + const anomalyTimestamps = new Set() + + for (const point of points) { + valueByTimestamp.set(point.timestamp, point.value) + + if (point.hasAnomaly) { + anomalyTimestamps.add(point.timestamp) + } + } + + const series = dates.map(timestamp => valueByTimestamp.get(timestamp) ?? 0) + const dashIndices = dates + .map((timestamp, index) => (anomalyTimestamps.has(timestamp) ? index : -1)) + .filter(index => index !== -1) + + const item: VueUiXyDatasetItem = { + name: applyEllipsis(packageName, 32), + type: 'line', + series, + dashIndices, + } + + if (isListedFramework(packageName)) { + item.color = getFrameworkColor(packageName) + } + + return item + }) + + return { dataset, dates } +} + +type TrendsNormalisedDatasetItem = VueUiXyDatasetItem & { + color?: string + series: number[] + dashIndices?: number[] +} + +export function buildNormalisedTrendsDataset(options: { + dataset: VueUiXyDatasetItem[] | null + dates: number[] + granularity: ChartTimeGranularity + selectedMetric: TrendMetricId + chartFilter: { + averageWindow: number + smoothingTau: number + predictionPoints?: number + } + endDateMs?: number | null +}): TrendsNormalisedDatasetItem[] { + const referenceMs = options.endDateMs ?? Date.now() + const lastDateMs = options.dates.at(-1) ?? 0 + const isAbsoluteMetric = options.selectedMetric === 'contributors' + + return (options.dataset ?? []).map(item => { + const sourceSeries = item.series.map(value => { + if (typeof value === 'number') { + return value + } + + if (value && typeof value === 'object' && typeof value.y === 'number') { + return value.y + } + + return 0 + }) + + const series = applyDataPipeline( + sourceSeries, + { + averageWindow: options.chartFilter.averageWindow, + smoothingTau: options.chartFilter.smoothingTau, + predictionPoints: + options.granularity === 'weekly' + ? 0 + : (options.chartFilter.predictionPoints ?? DEFAULT_PREDICTION_POINTS), + }, + { + granularity: options.granularity, + lastDateMs, + referenceMs, + isAbsoluteMetric, + }, + ) + + return Object.assign({}, item, { + series, + dashIndices: item.dashIndices ?? [], + }) + }) +} +export function getTrendsDatetimeFormatterOptions(granularity: ChartTimeGranularity) { + return { + daily: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, + weekly: { year: 'yyyy-MM-dd', month: 'yyyy-MM-dd', day: 'yyyy-MM-dd' }, + monthly: { year: 'MMM yyyy', month: 'MMM yyyy', day: 'MMM yyyy' }, + yearly: { year: 'yyyy', month: 'yyyy', day: 'yyyy' }, + }[granularity] +} + +// Some locales require more spacing for the last label value displayed on the chart, and for which some extra padding is reserved in the chart config. +export const LOCALES_WITH_EXTRA_SPACE = [ + 'bg-BG', + 'ru-RU', + 'cs-CZ', + 'de-AT', + 'de-DE', + 'id-ID', + 'it-IT', + 'ja-JP', + 'nb-NO', + 'nl-NL', + 'pl-PL', + 'pt-BR', + 'ro-RO', + 'sr-Latn-RS', + 'uk-UA', +] + +export function buildTrendsChartConfig( + options: TrendChartConfigOptions & { + dates: number[] + }, +): VueUiXyConfig { + return { + theme: options.isDarkMode ? 'dark' : ('' as VueDataUiTheme), + downsample: { + threshold: 5000, + }, + a11y: { + translations: { + keyboardNavigation: options.t( + 'package.trends.chart_assistive_text.keyboard_navigation_horizontal', + ), + tableAvailable: options.t('package.trends.chart_assistive_text.table_available'), + tableCaption: options.t('package.trends.chart_assistive_text.table_caption'), + }, + }, + chart: { + height: options.chartHeight, + backgroundColor: options.colors.bg, + padding: { + bottom: options.displayedGranularity === 'yearly' ? 84 : 64, + right: options.isMultiPackageMode + ? LOCALES_WITH_EXTRA_SPACE.includes(options.locale) + ? 180 + : 160 + : 145, + }, + userOptions: { + buttons: { + pdf: false, + labels: false, + fullscreen: false, + table: false, + tooltip: false, + altCopy: true, + }, + buttonTitles: { + csv: options.t('package.trends.download_file', { fileType: 'CSV' }), + img: options.t('package.trends.download_file', { fileType: 'PNG' }), + svg: options.t('package.trends.download_file', { fileType: 'SVG' }), + annotator: options.t('package.trends.toggle_annotator'), + stack: options.t('package.trends.toggle_stack_mode'), + altCopy: options.t('package.trends.copy_alt.button_label'), + open: options.t('package.trends.open_options'), + close: options.t('package.trends.close_options'), + }, + useCursorPointer: true, + }, + grid: { + position: 'start', + stroke: options.colors.border, + showHorizontalLines: true, + labels: { + fontSize: options.isMobile ? 24 : 16, + color: options.pending ? options.colors.border : options.colors.fgSubtle, + axis: { + yLabel: options.t('package.trends.y_axis_label', { + granularity: options.t(`package.trends.granularity_${options.selectedGranularity}`), + facet: options.selectedMetricLabel, + }), + yLabelOffsetX: 12, + fontSize: options.isMobile ? 32 : 24, + }, + xAxisLabels: { + show: true, + showOnlyAtModulo: true, + modulo: 12, + values: options.dates, + datetimeFormatter: { + enable: true, + locale: options.locale, + useUTC: true, + options: getTrendsDatetimeFormatterOptions(options.selectedGranularity), + }, + }, + yAxis: { + formatter: ({ value }: { value: number }) => { + return options.compactNumberFormatter.format(Number.isFinite(value) ? value : 0) + }, + useNiceScale: true, + gap: 24, + }, + }, + }, + timeTag: { + show: true, + backgroundColor: options.colors.bgElevated ?? options.colors.bg, + color: options.colors.fg, + fontSize: 16, + circleMarker: { + radius: 3, + color: options.colors.border, + }, + useDefaultFormat: true, + timeFormat: 'yyyy-MM-dd HH:mm:ss', + }, + highlighter: { + useLine: true, + }, + legend: { + show: false, + position: 'top', + }, + tooltip: { + teleportTo: options.inModal ? '#chart-modal' : undefined, + position: (options.tooltipPosition ?? 'center') as TextAlign, + offsetX: 24, + offsetY: options.isMultiPackageMode ? undefined : -24, + borderColor: 'transparent', + backdropFilter: false, + backgroundColor: 'transparent', + }, + }, + line: { + radius: 4, + useGradient: true, + dot: { + useSerieColor: true, + }, + labels: { + show: false, + }, + area: { + useGradient: true, + opacity: 12, + }, + }, + } +} + +export function drawTrendsEstimationLine(options: { + svg: Record + colors: TrendColors + shouldRender: boolean +}): string { + if (!options.shouldRender) { + return '' + } + + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const lines: string[] = [] + + for (const serie of data) { + const plots = serie?.plots + + if (!Array.isArray(plots) || plots.length < 2) { + continue + } + + const previousPoint = plots.at(-2) + const lastPoint = plots.at(-1) + + if (!previousPoint || !lastPoint) { + continue + } + + const stroke = String(serie?.color ?? options.colors.fg) + + lines.push(` + + + + `) + } + + return lines.join('\n') +} + +export function drawTrendsLastDatapointLabel(options: { + svg: Record + colors: TrendColors + compactNumberFormatter: TrendFormatter +}): string { + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const labels: string[] = [] + + for (const serie of data) { + const lastPlot = serie?.plots?.at(-1) + + if (!lastPlot) { + continue + } + + labels.push(` + + ${options.compactNumberFormatter.format(Number.isFinite(lastPlot.value) ? lastPlot.value : 0)} + + `) + } + + return labels.join('\n') +} + +export function drawTrendsSvgPrintLegend(options: { + svg: Record + colors: TrendColors + showEstimationLegend: boolean + estimationLabel: string +}): string { + const data = Array.isArray(options.svg?.data) + ? options.svg.data + : Array.isArray(options.svg?.series) + ? options.svg.series + : [] + + if (!data.length) { + return '' + } + + const output: string[] = [] + + data.forEach((serie, index) => { + output.push(` + + + ${serie.name} + + `) + }) + + if (options.showEstimationLegend) { + output.push(` + + + ${options.estimationLabel} + + `) + } + + return output.join('\n') +} + +export function generateWatermarkLogo({ + x, + y, + width, + height, + fill, +}: { + x: number + y: number + width: number + height: number + fill: string +}) { + return ` + + + + ` +} diff --git a/test/fixtures/jsdelivr-cdn/empathic@2.0.0/find.mjs b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/find.mjs new file mode 100644 index 0000000000..2c050ce0b4 --- /dev/null +++ b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/find.mjs @@ -0,0 +1,75 @@ +/* oxlint-disable */ +import { join } from 'node:path' +import { existsSync, statSync } from 'node:fs' +import * as walk from 'empathic/walk' +/** + * Find an item by name, walking parent directories until found. + * + * @param name The item name to find. + * @returns The absolute path to the item, if found. + */ +export function up(name, options) { + let dir, tmp + let start = (options && options.cwd) || '' + for (dir of walk.up(start, options)) { + tmp = join(dir, name) + if (existsSync(tmp)) return tmp + } +} +/** + * Get the first path that matches any of the names provided. + * + * > [NOTE] + * > The order of {@link names} is respected. + * + * @param names The item names to find. + * @returns The absolute path of the first item found, if any. + */ +export function any(names, options) { + const start = (options && options.cwd) || '' + const len = names.length + for (const dir of walk.up(start, options)) { + for (let j = 0; j < len; j++) { + const tmp = join(dir, names[j]) + if (existsSync(tmp)) return tmp + } + } +} +/** + * Find a file by name, walking parent directories until found. + * + * > [NOTE] + * > This function only returns a value for file matches. + * > A directory match with the same name will be ignored. + * + * @param name The file name to find. + * @returns The absolute path to the file, if found. + */ +export function file(name, options) { + const start = (options && options.cwd) || '' + for (const dir of walk.up(start, options)) { + try { + const tmp = join(dir, name) + if (statSync(tmp).isFile()) return tmp + } catch {} + } +} +/** + * Find a directory by name, walking parent directories until found. + * + * > [NOTE] + * > This function only returns a value for directory matches. + * > A file match with the same name will be ignored. + * + * @param name The directory name to find. + * @returns The absolute path to the file, if found. + */ +export function dir(name, options) { + const start = (options && options.cwd) || '' + for (const dir of walk.up(start, options)) { + try { + const tmp = join(dir, name) + if (statSync(tmp).isDirectory()) return tmp + } catch {} + } +} diff --git a/test/fixtures/jsdelivr-cdn/empathic@2.0.0/package.json b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/package.json new file mode 100644 index 0000000000..65ac290eb7 --- /dev/null +++ b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/package.json @@ -0,0 +1,49 @@ +{ + "name": "empathic", + "version": "2.0.0", + "description": "A set of small and fast Node.js utilities to understand your pathing needs.", + "license": "MIT", + "author": { + "name": "Luke Edwards", + "email": "luke.edwards05@gmail.com", + "url": "https://lukeed.com" + }, + "repository": "lukeed/empathic", + "exports": { + "./access": { + "types": "./access.d.ts", + "import": "./access.mjs", + "require": "./access.js" + }, + "./find": { + "types": "./find.d.ts", + "import": "./find.mjs", + "require": "./find.js" + }, + "./package": { + "types": "./package.d.ts", + "import": "./package.mjs", + "require": "./package.js" + }, + "./resolve": { + "types": "./resolve.d.ts", + "import": "./resolve.mjs", + "require": "./resolve.js" + }, + "./walk": { + "types": "./walk.d.ts", + "import": "./walk.mjs", + "require": "./walk.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "test": "uvu" + }, + "devDependencies": { + "uvu": "0.5" + }, + "engines": { + "node": ">=14" + } +} diff --git a/test/fixtures/jsdelivr-cdn/empathic@2.0.0/resolve.mjs b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/resolve.mjs new file mode 100644 index 0000000000..8b9379c491 --- /dev/null +++ b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/resolve.mjs @@ -0,0 +1,30 @@ +import { createRequire } from 'node:module' +import { isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +/** + * Resolve an absolute path from {@link root}, but only + * if {@link input} isn't already absolute. + * + * @param input The path to resolve. + * @param root The base path; default = process.cwd() + * @returns The resolved absolute path. + */ +export function absolute(input, root) { + return isAbsolute(input) ? input : resolve(root || '.', input) +} +export function from(root, ident, silent) { + try { + // NOTE: dirs need a trailing "/" OR filename. With "/" route, + // Node adds "noop.js" as main file, so just do "noop.js" anyway. + let r = + root instanceof URL || root.startsWith('file://') + ? join(fileURLToPath(root), 'noop.js') + : join(absolute(root), 'noop.js') + return createRequire(r).resolve(ident) + } catch (err) { + if (!silent) throw err + } +} +export function cwd(ident, silent) { + return from(resolve(), ident, silent) +} diff --git a/test/fixtures/jsdelivr-cdn/empathic@2.0.0/walk.mjs b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/walk.mjs new file mode 100644 index 0000000000..bf9a109cc8 --- /dev/null +++ b/test/fixtures/jsdelivr-cdn/empathic@2.0.0/walk.mjs @@ -0,0 +1,21 @@ +import { dirname } from 'node:path' +import { absolute } from 'empathic/resolve' +/** + * Get all parent directories of {@link base}. + * Stops after {@link Options['last']} is processed. + * + * @returns An array of absolute paths of all parent directories. + */ +export function up(base, options) { + let { last, cwd } = options || {} + let tmp = absolute(base, cwd) + let root = absolute(last || '/', cwd) + let prev, + arr = [] + while (prev !== root) { + arr.push(tmp) + tmp = dirname((prev = tmp)) + if (tmp === prev) break + } + return arr +} diff --git a/test/fixtures/jsdelivr/empathic.json b/test/fixtures/jsdelivr/empathic.json new file mode 100644 index 0000000000..3f8484dbc0 --- /dev/null +++ b/test/fixtures/jsdelivr/empathic.json @@ -0,0 +1,120 @@ +{ + "type": "npm", + "name": "empathic", + "version": "2.0.0", + "default": null, + "files": [ + { + "type": "file", + "name": "access.d.ts", + "hash": "BrF3r4FaA0GzI59lpo+Fga4gUk3RW/S1KBsFsfqa0Uc=", + "size": 618 + }, + { + "type": "file", + "name": "access.js", + "hash": "fOI6MfpljmYsMFoKxlT3hlksRGV+CzTG4PV17Tzj9bw=", + "size": 811 + }, + { + "type": "file", + "name": "access.mjs", + "hash": "XwPL8Rwp7LRA2P4L7k83MiVgJm+9ijt79kY2vYP1xfc=", + "size": 726 + }, + { + "type": "file", + "name": "find.d.ts", + "hash": "pnvypI9UlFlmFC/HEOemsr9P0LpvNLOdDHcxgWjReuI=", + "size": 1381 + }, + { + "type": "file", + "name": "find.js", + "hash": "4gQt2eiJdVuSz8qXxpVa7fSZ4my86ppZE559Axg1ZBA=", + "size": 2091 + }, + { + "type": "file", + "name": "find.mjs", + "hash": "lvJ7HCj4b9ulEfJmIu0emj6pPI/n3YsIKU6s/O2x1Zk=", + "size": 2033 + }, + { + "type": "file", + "name": "license", + "hash": "mp7a17quUmIr3fPBWy74oz0sifLSVAitE+inSBxrDJc=", + "size": 1104 + }, + { + "type": "file", + "name": "package.d.ts", + "hash": "JsRlIyi79zNkf8ANVGzKc3gREEhag4EuUipOTxyTHUI=", + "size": 1004 + }, + { + "type": "file", + "name": "package.js", + "hash": "+w109RqMZ0fjqw4X/RHR+yJCD0tb5NB/QBFcMOHPX9s=", + "size": 1696 + }, + { + "type": "file", + "name": "package.json", + "hash": "7qOfQHA0O99rY/ZP/hrK6E9yYxMNXbODNS9Sqjb/oVY=", + "size": 997 + }, + { + "type": "file", + "name": "package.mjs", + "hash": "ZHkKNK56yB982/jqiLzBxBX5/9oHD5J0Nzg/3OpmXAk=", + "size": 1650 + }, + { + "type": "file", + "name": "readme.md", + "hash": "gDnGtevykzLCMhqswkdNy0dc07Qa92MY2qvCeJ1MwEE=", + "size": 2336 + }, + { + "type": "file", + "name": "resolve.d.ts", + "hash": "ektazhZ/Xm9pQygPANVhSsn0DJ7TjBBPrORhvsAFLVE=", + "size": 1198 + }, + { + "type": "file", + "name": "resolve.js", + "hash": "pvj0JULeAbxLjvdmYSR6H/TL4QR+xwbwXBc2kwd7gCs=", + "size": 1034 + }, + { + "type": "file", + "name": "resolve.mjs", + "hash": "qGhTjwjoVA0gs8GDGeu3vEN614gN3NfyUAGFTClz61g=", + "size": 971 + }, + { + "type": "file", + "name": "walk.d.ts", + "hash": "lQsJOYxJZxLobTYn5gRgJJpxoBx42ABi/hLS30rTCQQ=", + "size": 482 + }, + { + "type": "file", + "name": "walk.js", + "hash": "O3GSAQ7+J1ETKRIf2d1gB6/r8ef1Ap6z0pHB3s7dN7s=", + "size": 555 + }, + { + "type": "file", + "name": "walk.mjs", + "hash": "d4kE/ieohzkyhmxdH1nAT2O4sR/BA4EUCFFBXJTvx2w=", + "size": 535 + } + ], + "links": { + "stats": "https://data.jsdelivr.com/v1/stats/packages/npm/empathic@2.0.0", + "entrypoints": "https://data.jsdelivr.com/v1/packages/npm/empathic@2.0.0/entrypoints" + } +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 880daab411..0915d94d26 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -158,15 +158,21 @@ import { BuildEnvironment, ButtonBase, LandingIntroHeader, + NoodleArtemisLogo, NoodleKawaiiLogo, + NoodleTransgenderVisibilityLogo, + NoodleListCard, NoodleNodejsLogo, + NoodlePressLogo, NoodlePride1Logo, NoodlePride2Logo, + NoodleLens, NoodlePride3Logo, NoodleTetrisLogo, LinkBase, CallToAction, ChangelogCard, + ChangelogSkeleton, ChangelogErrorMsg, CodeDirectoryListing, CodeFileTree, @@ -201,7 +207,6 @@ import { OrgTeamsPanel, PackageAccessControls, PackageCard, - PackageChartModal, PackageClaimPackageModal, PackageCompatibility, PackageDependencies, @@ -378,12 +383,30 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) + it('should have no accessibility violations', async () => { + const component = await mountSuspended(NoodleTransgenderVisibilityLogo) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(NoodlePressLogo) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + it('should have no accessibility violations', async () => { const component = await mountSuspended(NoodleNodejsLogo) const results = await runAxe(component) expect(results.violations).toEqual([]) }) + it('should have no accessibility violations', async () => { + const component = await mountSuspended(NoodleArtemisLogo) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + it('should have no accessibility violations', async () => { const component = await mountSuspended(NoodlePride1Logo) const results = await runAxe(component) @@ -407,6 +430,16 @@ describe('component accessibility audits', () => { const results = await runAxe(component) expect(results.violations).toEqual([]) }) + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(NoodleLens, { + props: { + logo: NoodleKawaiiLogo, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) }) describe('AppFooter', () => { @@ -962,12 +995,12 @@ describe('component accessibility audits', () => { homepage: 'https://react.dev', repository: { type: 'git', - url: 'https://github.com/facebook/react.git', + url: 'https://github.com/react/react.git', }, bugs: { - url: 'https://github.com/facebook/react/issues', + url: 'https://github.com/react/react/issues', }, - funding: 'https://github.com/sponsors/facebook', + funding: 'https://github.com/facebook', dist: { shasum: 'abc123def456', tarball: 'https://registry.npmjs.org/react/-/react-18.2.0.tgz', @@ -1091,22 +1124,6 @@ describe('component accessibility audits', () => { // component has issues in the test environment (requires DOM measurements that aren't // available during SSR-like test mounting). - describe('PackageChartModal', () => { - it('should have no accessibility violations when closed', async () => { - const component = await mountSuspended(PackageChartModal, { - props: { open: false, title: 'Downloads' }, - slots: { default: '
Chart content
' }, - }) - const results = await runAxe(component) - expect(results.violations).toEqual([]) - }) - - // Note: Testing the open state is challenging because native .showModal() - // requires the element to be in the DOM and connected, which doesn't work well - // with the test environment's cloning approach. The dialog accessibility is - // inherently provided by the native element with aria-labelledby. - }) - describe('PackageTrendsChart', () => { const mockWeeklyDownloads = [ { @@ -1670,6 +1687,18 @@ describe('component accessibility audits', () => { const results = await runAxe(component) expect(results.violations).toEqual([]) }) + + it('should have no accessibility violations with import-link markup', async () => { + const component = await mountSuspended(CodeViewer, { + props: { + html: '
vite
', + lines: 1, + selectedLines: null, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) }) describe('CodeDirectoryListing', () => { @@ -2710,6 +2739,7 @@ describe('component accessibility audits', () => { id: 'a11y', title: '1.0.0', publishedAt: '2026-02-11 10:00:00.000Z', + link: 'https://github.com/nuxt/nuxt/releases/tag/v4.4.5', }, tocHeaderClass: 'toc', }, @@ -2718,6 +2748,12 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) + it('ChangelogSkeleton', async () => { + const component = await mountSuspended(ChangelogSkeleton) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + it('ChangelogErrorMsg should have no accessibility violations for warning variant', async () => { const component = await mountSuspended(ChangelogErrorMsg, { props: { @@ -2928,6 +2964,24 @@ describe('component accessibility audits', () => { }) }) + describe('NoodleListCard', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(NoodleListCard, { + props: { + noodle: { + key: 'press', + slug: 'press', + title: 'Press', + date: '2026-05-01', + dateTo: '2026-05-04', + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('BlueskyComment', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(BlueskyComment, { diff --git a/test/nuxt/app/utils/header-version-matcher.spec.ts b/test/nuxt/app/utils/header-version-matcher.spec.ts new file mode 100644 index 0000000000..8bf28c78d5 --- /dev/null +++ b/test/nuxt/app/utils/header-version-matcher.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createHeadingVersionMatcher } from '~/utils/header-version-matcher' + +describe('createHeadingVersionMatcher', () => { + it('should only match version 3.5.1', () => { + const isMatching = createHeadingVersionMatcher('3.5.1') + + expect(isMatching('test-pkg@3.5.1')).toBe(true) + expect(isMatching('test-pkg: 3.5.1')).toBe(true) + expect(isMatching('v3.5.1 (2026-03-03)')).toBe(true) + expect(isMatching('3.5.1')).toBe(true) + expect(isMatching('3.5.1/3.5.2')).toBe(true) + expect(isMatching('v3.5.0/v3.5.1')).toBe(true) + + expect(isMatching('test-pkg@3.5.15')).toBe(false) + expect(isMatching('test-pkg: 3.5.15')).toBe(false) + expect(isMatching('v3.5.15 (2026-03-03)')).toBe(false) + expect(isMatching('3.5.15')).toBe(false) + expect(isMatching('3.5.12/3.5.20')).toBe(false) + expect(isMatching('3.5.9/3.5.10')).toBe(false) + }) +}) diff --git a/test/nuxt/components/CodeViewer.spec.ts b/test/nuxt/components/CodeViewer.spec.ts new file mode 100644 index 0000000000..7ea43a151e --- /dev/null +++ b/test/nuxt/components/CodeViewer.spec.ts @@ -0,0 +1,159 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { nextTick } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CodeViewer from '~/components/Code/Viewer.vue' + +const html = [ + '
',
+  'import { ref } from "vue"',
+  'const count = ref(0)',
+  'export { count }',
+  '
', +].join('') + +const updatedHtml = html.replace('const count = ref(0)', 'const counter = ref(0)') + +async function mountCodeViewer( + selectedLines: { start: number; end: number } | null = null, + componentHtml: string = html, +) { + return mountSuspended(CodeViewer, { + attachTo: document.body, + props: { + html: componentHtml, + lines: 3, + selectedLines, + }, + }) +} + +function getRenderedCodeLines(wrapper: Awaited>) { + const root = wrapper.element as HTMLElement + return Array.from(root.querySelectorAll('code > .line')) as HTMLElement[] +} + +describe('CodeViewer', () => { + afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('renders line numbers, highlights the selected range, and emits clicks', async () => { + const wrapper = await mountCodeViewer() + + try { + const lineNumbers = wrapper.findAll('.line-number') + expect(lineNumbers).toHaveLength(3) + expect(lineNumbers.map(line => line.text())).toEqual(['1', '2', '3']) + expect(wrapper.find('.line-numbers').attributes('style')).toContain('--line-digits: 1') + + await wrapper.setProps({ selectedLines: { start: 2, end: 3 } }) + await nextTick() + await vi.waitFor(() => { + const codeLines = getRenderedCodeLines(wrapper) + expect(codeLines[0]?.classList.contains('highlighted')).toBe(false) + expect(codeLines[1]?.classList.contains('highlighted')).toBe(true) + expect(codeLines[2]?.classList.contains('highlighted')).toBe(true) + }) + + expect(lineNumbers[0]?.classes()).toContain('text-fg-subtle') + expect(lineNumbers[1]?.classes()).toContain('bg-yellow-500/20') + expect(lineNumbers[2]?.classes()).toContain('bg-yellow-500/20') + + await lineNumbers[1]!.trigger('click') + expect(wrapper.emitted('lineClick')).toHaveLength(1) + expect(wrapper.emitted('lineClick')?.[0]?.[0]).toBe(2) + expect(wrapper.emitted('lineClick')?.[0]?.[1]).toBeInstanceOf(MouseEvent) + } finally { + wrapper.unmount() + } + }) + + it('updates highlighted lines when the selected range changes or clears', async () => { + const wrapper = await mountCodeViewer() + + try { + await wrapper.setProps({ selectedLines: { start: 1, end: 1 } }) + await nextTick() + await vi.waitFor(() => { + expect(getRenderedCodeLines(wrapper)[0]?.classList.contains('highlighted')).toBe(true) + }) + + await wrapper.setProps({ selectedLines: { start: 3, end: 3 } }) + await nextTick() + await vi.waitFor(() => { + const codeLines = getRenderedCodeLines(wrapper) + expect(codeLines[0]?.classList.contains('highlighted')).toBe(false) + expect(codeLines[1]?.classList.contains('highlighted')).toBe(false) + expect(codeLines[2]?.classList.contains('highlighted')).toBe(true) + }) + + await wrapper.setProps({ selectedLines: null }) + await nextTick() + await vi.waitFor(() => { + getRenderedCodeLines(wrapper).forEach(line => { + expect(line.classList.contains('highlighted')).toBe(false) + }) + }) + } finally { + wrapper.unmount() + } + }) + + it('routes import-link clicks through vue-router and preserves modifier-assisted navigation', async () => { + const wrapper = await mountCodeViewer(null, updatedHtml) + + try { + const router = useRouter() + const pushSpy = vi.spyOn(router, 'push').mockImplementation(() => Promise.resolve()) + + await wrapper.setProps({ html }) + await nextTick() + const anchor = wrapper.find('a.import-link') + expect(anchor.exists()).toBe(true) + + const plainClick = new MouseEvent('click', { bubbles: true, cancelable: true }) + anchor.element.dispatchEvent(plainClick) + + expect(plainClick.defaultPrevented).toBe(true) + expect(pushSpy).toHaveBeenCalledWith('#package-vue') + + const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }) + anchor.element.dispatchEvent(ctrlClick) + + expect(ctrlClick.defaultPrevented).toBe(false) + expect(pushSpy).toHaveBeenCalledTimes(1) + } finally { + wrapper.unmount() + } + }) + + it('ignores import-link clicks without an href', async () => { + const router = useRouter() + const pushSpy = vi.spyOn(router, 'push').mockImplementation(() => Promise.resolve()) + + const wrapper = await mountSuspended(CodeViewer, { + attachTo: document.body, + props: { + html: '
placeholder
', + lines: 1, + selectedLines: null, + }, + }) + + try { + await wrapper.setProps({ + html: '
missing href
', + }) + const anchor = wrapper.find('a.import-link') + await nextTick() + const click = new MouseEvent('click', { bubbles: true, cancelable: true }) + anchor.element.dispatchEvent(click) + + expect(click.defaultPrevented).toBe(false) + expect(pushSpy).not.toHaveBeenCalled() + } finally { + wrapper.unmount() + } + }) +}) diff --git a/test/nuxt/components/CommandPalette.spec.ts b/test/nuxt/components/CommandPalette.spec.ts index 3570d63ab4..3678730578 100644 --- a/test/nuxt/components/CommandPalette.spec.ts +++ b/test/nuxt/components/CommandPalette.spec.ts @@ -98,6 +98,17 @@ describe('CommandPalette', () => { expect(input?.getAttribute('aria-controls')).toBe('command-palette-modal-results') }) + it('shows keyboard shortcut hints in the command palette', async () => { + await mountPalette() + + const shortcuts = document.querySelector('[data-command-palette-keyboard-shortcuts="true"]') + + expect(shortcuts).not.toBeNull() + expect(shortcuts?.textContent).toContain('to navigate') + expect(shortcuts?.textContent).toContain('to select') + expect(shortcuts?.textContent).toContain('to close') + }) + it('updates the live region when the query changes', async () => { await mountPalette() diff --git a/test/nuxt/components/Package/Versions.spec.ts b/test/nuxt/components/Package/Versions.spec.ts index a1aa43c67c..ff8220391b 100644 --- a/test/nuxt/components/Package/Versions.spec.ts +++ b/test/nuxt/components/Package/Versions.spec.ts @@ -38,7 +38,8 @@ function isVersionLink(a: DOMWrapper): boolean { return ( !a.attributes('href')?.startsWith('#') && a.attributes('target') !== '_blank' && - !a.attributes('data-testid')?.includes('view-all-versions') + !a.attributes('data-testid')?.includes('view-distribution-link') && + !a.attributes('data-testid')?.includes('view-all-versions-link') ) } diff --git a/test/nuxt/components/compare/FacetSelector.spec.ts b/test/nuxt/components/compare/FacetSelector.spec.ts index 11904946c7..d90ee51c5a 100644 --- a/test/nuxt/components/compare/FacetSelector.spec.ts +++ b/test/nuxt/components/compare/FacetSelector.spec.ts @@ -33,6 +33,7 @@ const facetLabels: Record { }) describe('github metadata', () => { - it('fetches github stars and issues when repository is on github', async () => { + it('fetches github stars, forks, and issues when repository is on github', async () => { const pkgName = 'github-pkg' vi.stubGlobal( '$fetch', @@ -211,7 +211,7 @@ describe('usePackageComparison', () => { }) } if (fullUrl.includes('ungh.cc/repos/owner/repo')) { - return Promise.resolve({ repo: { stars: 1500 } }) + return Promise.resolve({ repo: { stars: 1500, forks: 100 } }) } if (fullUrl.includes('/api/github/issues/owner/repo')) { return Promise.resolve({ issues: 50 }) @@ -226,9 +226,11 @@ describe('usePackageComparison', () => { }) const stars = getFacetValues('githubStars')[0] + const forks = getFacetValues('githubForks')[0] const issues = getFacetValues('githubIssues')[0] expect(stars).toMatchObject({ raw: 1500, status: 'neutral' }) + expect(forks).toMatchObject({ raw: 100, status: 'neutral' }) expect(issues).toMatchObject({ raw: 50, status: 'neutral' }) }) @@ -266,6 +268,7 @@ describe('usePackageComparison', () => { }) expect(getFacetValues('githubStars')[0]).toBeNull() + expect(getFacetValues('githubForks')[0]).toBeNull() expect(getFacetValues('githubIssues')[0]).toBeNull() }) @@ -298,10 +301,132 @@ describe('usePackageComparison', () => { expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/github/issues')) expect(getFacetValues('githubStars')[0]).toBeNull() + expect(getFacetValues('githubForks')[0]).toBeNull() expect(getFacetValues('githubIssues')[0]).toBeNull() }) }) + describe('custom version', () => { + it('fetches version-specific data when a version is pinned in the spec', async () => { + const requestedUrls: string[] = [] + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + requestedUrls.push(fullUrl) + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '2.0.0' }, + 'time': { + 'modified': '2025-01-01T00:00:00.000Z', + '1.0.0': '2022-03-20T00:00:00.000Z', + '2.0.0': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0': { dist: { unpackedSize: 20000 } }, + }, + }) + } + if (fullUrl.includes('/api/registry/install-size/')) { + return Promise.resolve({ selfSize: 1, totalSize: 2, dependencyCount: 3 }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status, getFacetValues } = await usePackageComparisonInComponent([ + 'test-package@1.0.0', + ]) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + // Uses the pinned version, not the latest dist-tag + expect(packagesData.value[0]?.package.version).toBe('1.0.0') + + // Version-specific metadata and size + expect(packagesData.value[0]?.metadata?.lastUpdated).toBe('2022-03-20T00:00:00.000Z') + expect(getFacetValues('packageSize')[0]?.raw).toBe(10000) + + // Version-aware endpoints are hit with the /v/ segment + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/analysis/test-package/v/1.0.0'), + ) + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/vulnerabilities/test-package/v/1.0.0'), + ) + await vi.waitFor(() => { + expect(requestedUrls).toContainEqual( + expect.stringContaining('/api/registry/install-size/test-package/v/1.0.0'), + ) + }) + }) + + it('resolves a dist-tag spec to its concrete version', async () => { + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0', next: '2.0.0-beta.1' }, + 'time': { + '1.0.0': '2024-01-01T00:00:00.000Z', + '2.0.0-beta.1': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0-beta.1': { dist: { unpackedSize: 20000 } }, + }, + }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status } = await usePackageComparisonInComponent(['test-package@next']) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(packagesData.value[0]?.package.version).toBe('2.0.0-beta.1') + }) + + it('returns null when the pinned version cannot be resolved', async () => { + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + }, + }) + } + return Promise.resolve(null) + }), + ) + + const { packagesData, status } = await usePackageComparisonInComponent(['test-package@9.9.9']) + + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(packagesData.value[0]).toBeNull() + }) + }) + describe('createdAt facet', () => { it('displays the creation date without status', async () => { const createdDate = '2020-01-01T00:00:00.000Z' diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 1f5570aaa3..86f8371613 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -27,6 +27,7 @@ const SKIPPED_COMPONENTS: Record = { 'OgImage/BlogPost.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Compare.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Package.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', + 'OgImage/Noodle.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Page.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Profile.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Splash.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', @@ -55,10 +56,11 @@ const SKIPPED_COMPONENTS: Record = { 'SkeletonBlock.vue': 'Already covered indirectly via other component tests', 'SkeletonInline.vue': 'Already covered indirectly via other component tests', 'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here", - 'Changelog/Releases.vue': 'Requires API calls', - 'Changelog/Markdown.vue': 'Requires API call & only renders markdown html', + 'Changelog/Releases.vue': 'Requires API calls & only renders ChangelogCard components in a list', + 'Changelog/Markdown.vue': 'Requires API call & mostly renders markdown html', 'Translation/StatusByFile.unused.vue': 'Unused component, might be needed in the future', 'ColorScheme/Img.vue': 'Image component, basic ui', + 'VideoPlayer.vue': 'Atproto video component, basic ui', } function normalizeComponentPath(filePath: string): string { diff --git a/test/unit/app/composables/use-chart-tooltip-position.spec.ts b/test/unit/app/composables/use-chart-tooltip-position.spec.ts deleted file mode 100644 index 70429da797..0000000000 --- a/test/unit/app/composables/use-chart-tooltip-position.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { computed } from 'vue' -import { ref, shallowRef } from 'vue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' - -const mouseState = vi.hoisted(() => ({ - target: null as unknown, -})) - -const elementX = ref(0) -const elementWidth = ref(0) -const isOutside = ref(true) - -const MockHTMLElement = class { - public readonly nodeType = 1 -} - -vi.stubGlobal('HTMLElement', MockHTMLElement) - -afterEach(() => { - vi.unstubAllGlobals() -}) - -vi.mock('@vueuse/core', () => ({ - useMouseInElement: vi.fn(target => { - mouseState.target = target - - return { - elementX, - elementWidth, - isOutside, - } - }), -})) - -describe('useChartTooltipPosition', () => { - beforeEach(() => { - vi.stubGlobal('HTMLElement', MockHTMLElement) - elementX.value = 0 - elementWidth.value = 0 - isOutside.value = true - mouseState.target = null - }) - - it('returns center when the mouse is outside', () => { - const element = new MockHTMLElement() as HTMLElement - const position = useChartTooltipPosition(shallowRef(element)) - - isOutside.value = true - elementWidth.value = 100 - elementX.value = 75 - - expect(position.value).toBe('center') - }) - - it('returns center when element width is 0', () => { - const element = new MockHTMLElement() as HTMLElement - const position = useChartTooltipPosition(shallowRef(element)) - isOutside.value = false - elementWidth.value = 0 - elementX.value = 75 - expect(position.value).toBe('center') - }) - - it('returns left when the mouse is on the right half of the element', () => { - const element = new MockHTMLElement() as HTMLElement - const position = useChartTooltipPosition(shallowRef(element)) - isOutside.value = false - elementWidth.value = 100 - elementX.value = 51 - expect(position.value).toBe('left') - }) - - it('returns right when the mouse is on the left half of the element', () => { - const element = new MockHTMLElement() as HTMLElement - const position = useChartTooltipPosition(shallowRef(element)) - isOutside.value = false - elementWidth.value = 100 - elementX.value = 49 - expect(position.value).toBe('right') - }) - - it('returns right when the mouse is exactly at the center', () => { - const element = new MockHTMLElement() as HTMLElement - const position = useChartTooltipPosition(shallowRef(element)) - isOutside.value = false - elementWidth.value = 100 - elementX.value = 50 - expect(position.value).toBe('right') - }) - - it('accepts a Vue component ref exposing $el', () => { - const element = new MockHTMLElement() as HTMLElement - const componentReference = shallowRef({ $el: element }) - useChartTooltipPosition(componentReference) - expect((mouseState.target as ReturnType).value).toBe(element) - }) - - it('returns null as target when ref value is null', () => { - useChartTooltipPosition(shallowRef(null)) - expect((mouseState.target as ReturnType).value).toBe(null) - }) - - it('returns null when component ref has no $el', () => { - const componentReference = shallowRef({}) - useChartTooltipPosition(componentReference) - expect((mouseState.target as ReturnType).value).toBe(null) - }) - - it('uses the HTMLElement directly as target', () => { - const element = new MockHTMLElement() as HTMLElement - useChartTooltipPosition(shallowRef(element)) - expect((mouseState.target as ReturnType).value).toBe(element) - }) - - it('uses the component $el as target', () => { - const element = new MockHTMLElement() as HTMLElement - const componentReference = shallowRef({ $el: element }) - useChartTooltipPosition(componentReference) - expect((mouseState.target as ReturnType).value).toBe(element) - }) -}) diff --git a/test/unit/app/utils/charts.spec.ts b/test/unit/app/utils/charts.spec.ts index ae2ff47593..bd5ff80513 100644 --- a/test/unit/app/utils/charts.spec.ts +++ b/test/unit/app/utils/charts.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { sum, chunkIntoWeeks, @@ -13,7 +13,6 @@ import { copyAltTextForVersionsBarChart, createAltTextForTimelineChart, copyAltTextForTimelineChart, - loadFile, sanitise, insertLineBreaks, applyEllipsis, @@ -1278,57 +1277,6 @@ describe('copyAltTextForTimelineChart', () => { }) }) -describe('loadFile', () => { - let createElementMock: ReturnType - let clickMock: ReturnType - let removeMock: ReturnType - let originalDocument: typeof globalThis.document | undefined - - beforeEach(() => { - clickMock = vi.fn() - removeMock = vi.fn() - - createElementMock = vi.fn().mockReturnValue({ - href: '', - download: '', - click: clickMock, - remove: removeMock, - }) - - originalDocument = globalThis.document - - Object.defineProperty(globalThis, 'document', { - value: { - createElement: createElementMock, - }, - configurable: true, - writable: true, - }) - }) - - afterEach(() => { - vi.restoreAllMocks() - - Object.defineProperty(globalThis, 'document', { - value: originalDocument, - configurable: true, - writable: true, - }) - }) - - it('creates an anchor element and triggers a download', () => { - const link = 'https://npmx.dev/file.png' - const filename = 'file.png' - loadFile(link, filename) - expect(createElementMock).toHaveBeenCalledWith('a') - const anchor = createElementMock.mock.results[0]?.value as HTMLAnchorElement - expect(anchor.href).toBe(link) - expect(anchor.download).toBe(filename) - expect(clickMock).toHaveBeenCalledTimes(1) - expect(removeMock).toHaveBeenCalledTimes(1) - }) -}) - describe('sanitise', () => { it('returns the same string when no sanitisation is needed', () => { expect(sanitise('nuxt-package')).toBe('nuxt-package') diff --git a/test/unit/app/utils/date.spec.ts b/test/unit/app/utils/date.spec.ts index 34517e6aa3..ee17fb377f 100644 --- a/test/unit/app/utils/date.spec.ts +++ b/test/unit/app/utils/date.spec.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest' -import { addDays, DAY_MS, daysInMonth, daysInYear, parseIsoDate, toIsoDate } from '~/utils/date' +import { describe, expect, it, vi } from 'vitest' +import { + addDays, + DAY_MS, + daysInMonth, + daysInYear, + parseIsoDate, + toIsoDate, + getEffectiveEndDateIso, + isLastDayOfMonth, + isLastDayOfYear, +} from '~/utils/date' describe('DAY_MS', () => { it('equals 86 400 000', () => { @@ -89,3 +99,76 @@ describe('daysInYear', () => { expect(daysInYear(2000)).toBe(366) }) }) + +describe('getEffectiveEndDateIso', () => { + it('returns the provided end date when present', () => { + expect(getEffectiveEndDateIso('2026-05-31')).toBe('2026-05-31') + }) + + it('returns yesterday in UTC when no end date is provided', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2026-05-31') + + vi.useRealTimers() + }) + + it('handles UTC month boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-01T00:30:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2026-02-28') + + vi.useRealTimers() + }) + + it('handles UTC year boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:30:00.000Z')) + + expect(getEffectiveEndDateIso()).toBe('2025-12-31') + + vi.useRealTimers() + }) +}) + +describe('isLastDayOfMonth', () => { + it('returns true for the last day of a 31-day month', () => { + expect(isLastDayOfMonth('2026-01-31')).toBe(true) + }) + + it('returns true for the last day of a 30-day month', () => { + expect(isLastDayOfMonth('2026-04-30')).toBe(true) + }) + + it('returns true for February 29 in a leap year', () => { + expect(isLastDayOfMonth('2024-02-29')).toBe(true) + }) + + it('returns true for February 28 in a non-leap year', () => { + expect(isLastDayOfMonth('2023-02-28')).toBe(true) + }) + + it('returns false when the date is not the last day of the month', () => { + expect(isLastDayOfMonth('2026-05-30')).toBe(false) + }) +}) + +describe('isLastDayOfYear', () => { + it('returns true for December 31', () => { + expect(isLastDayOfYear('2026-12-31')).toBe(true) + }) + + it('returns false for any other day in December', () => { + expect(isLastDayOfYear('2026-12-30')).toBe(false) + }) + + it('returns false for the last day of a month that is not December', () => { + expect(isLastDayOfYear('2026-11-30')).toBe(false) + }) + + it('returns true for leap years on December 31', () => { + expect(isLastDayOfYear('2024-12-31')).toBe(true) + }) +}) diff --git a/test/unit/server/utils/changelog/markdown.spec.ts b/test/unit/server/utils/changelog/markdown.spec.ts new file mode 100644 index 0000000000..05302f3277 --- /dev/null +++ b/test/unit/server/utils/changelog/markdown.spec.ts @@ -0,0 +1,542 @@ +import type { MarkdownRepoInfo } from '~~/server/utils/changelog/markdown' +import { describe, expect, it, vi, beforeAll } from 'vitest' + +// testing changelog specific needs, others things are tested at ../readme.spec.ts + +beforeAll(() => { + vi.stubGlobal( + 'getShikiHighlighter', + vi.fn().mockResolvedValue({ + getLoadedLanguages: () => [], + codeToHtml: (code: string) => `
${code}
`, + }), + ) + vi.stubGlobal( + 'useRuntimeConfig', + vi.fn().mockReturnValue({ + imageProxySecret: 'test-secret-for-readme-tests', + }), + ) +}) + +const { changelogRenderer } = await import('#server/utils/changelog/markdown') + +function changelogMdinfo(): MarkdownRepoInfo { + return { + blobBaseUrl: `https://github.com/test-owner/test-repo/blob/HEAD`, + rawBaseUrl: `https://raw.githubusercontent.com/test-owner/test-repo/HEAD`, + } +} + +function changelogMdInfoWithPath() { + return { + blobBaseUrl: `https://github.com/test-owner/test-repo/blob/HEAD`, + rawBaseUrl: `https://raw.githubusercontent.com/test-owner/test-repo/HEAD`, + path: 'packages/test/changelog.md', + } +} + +describe('URL Resolution', () => { + describe('resolves from /markdown.md & releases', () => { + it('resolves relative .md links to blob URL for rendered viewing', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Contributing](./CONTRIBUTING.md)` + const result = renderer(markdown) + + expect(result.html).toContain( + `href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md"`, + ) + }) + + it('resolves without ./ or / .md links to blob URL', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Guide](GUIDE.MD)` + const result = renderer(markdown) + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/GUIDE.MD"', + ) + }) + + it('resolves absolute .md links to blob URL', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Security](/SECURITY.MD)` + + const result = renderer(markdown) + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/SECURITY.MD"', + ) + }) + + it('resolves nested relative .md links to blob URL', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[API Docs](./docs/api/reference.md)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/docs/api/reference.md"', + ) + }) + + it('resolves relative .md links with query strings to blob URL', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[FAQ](./FAQ.md?ref=main)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/FAQ.md?ref=main"', + ) + }) + + it('resolves relative .md links with anchors to blob URL', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Install Section](./CONTRIBUTING.md#installation)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/CONTRIBUTING.md#installation"', + ) + }) + + it('resolves non-.md files to raw URL (not blob)', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Image](./assets/logo.png)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/assets/logo.png"', + ) + }) + + it('resolves to the root when going to far back', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[License](../../../LICENSE)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/LICENSE"', + ) + }) + }) + + describe('resolves from a deeper changelog.md', () => { + it('resolves relative .md links to blob URL for rendered viewing', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[Contributing](./CONTRIBUTING.md)` + const result = renderer(markdown) + + expect(result.html).toContain( + `href="https://github.com/test-owner/test-repo/blob/HEAD/packages/test/CONTRIBUTING.md"`, + ) + }) + + it('resolves without ./ or / .md links to a relative blob URL', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[Guide](GUIDE.MD)` + const result = renderer(markdown) + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/test/GUIDE.MD"', + ) + }) + + it('resolves absolute .md links to blob URL', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[Security](/SECURITY.MD)` + + const result = renderer(markdown) + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/SECURITY.MD"', + ) + }) + + it('resolves nested relative .md links to blob URL', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[API Docs](./docs/api/reference.md)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/test/docs/api/reference.md"', + ) + }) + + it('resolves relative .md links with query strings to blob URL', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[FAQ](./FAQ.md?ref=main)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/test/FAQ.md?ref=main"', + ) + }) + + it('resolves relative .md links with anchors to blob URL', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[Install Section](./CONTRIBUTING.md#installation)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://github.com/test-owner/test-repo/blob/HEAD/packages/test/CONTRIBUTING.md#installation"', + ) + }) + + it('resolves non-.md files to raw URL (not blob)', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[Image](./assets/logo.png)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/packages/test/assets/logo.png"', + ) + }) + + it('resolves to the root when going to far back', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[License](../../../LICENSE)` + const result = renderer(markdown) + + expect(result.html).toContain( + 'href="https://raw.githubusercontent.com/test-owner/test-repo/HEAD/LICENSE"', + ) + }) + }) + + describe('resolves full urls', () => { + it('leaves absolute .md URLs unchanged', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `[External Guide](https://example.com/guide.md)` + const result = renderer(markdown) + expect(result.html).toContain('href="https://example.com/guide.md"') + }) + + it('leaves absolute non-.md URLs unchanged', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Docs](https://docs.example.com/)` + const result = renderer(markdown) + expect(result.html).toContain('href="https://docs.example.com/"') + }) + }) + + describe('anchor links', () => { + describe('for changelog.md', () => { + it('prefixes anchor links with user-content-', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + + const markdown = `[Jump to section](#installation)` + const result = renderer(markdown) + + expect(result.html).toContain('href="#user-content-installation"') + }) + + it('normalizes mixed-case heading fragments to lowercase slugs', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Associations section](#Associations)` + const result = renderer(markdown) + + expect(result.html).toContain('href="#user-content-associations"') + }) + }) + + describe('for releases', () => { + it('prefixes anchor links with user-content-', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + + const markdown = `[Jump to section](#installation)` + const result = renderer(markdown, '123456789') + + expect(result.html).toContain('href="#user-content-123456789-installation"') + }) + + it('normalizes mixed-case heading fragments to lowercase slugs', async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `[Associations section](#Associations)` + const result = renderer(markdown, 123456789) + + expect(result.html).toContain('href="#user-content-123456789-associations"') + }) + }) + }) + + describe('npm.js urls', () => { + it('redirects npmjs.com urls to local', async () => { + const markdown = `[Some npmjs.com link](https://www.npmjs.com/package/test-pkg)` + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const result = renderer(markdown) + + expect(result.html).toContain('href="/package/test-pkg"') + }) + + it('redirects npmjs.com urls to local (no www and http)', async () => { + const markdown = `[Some npmjs.com link](http://npmjs.com/package/test-pkg)` + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const result = renderer(markdown) + + expect(result.html).toContain('href="/package/test-pkg"') + }) + + it('does not redirect npmjs.com to local if they are in the list of exceptions', async () => { + const markdown = `[Root Contributing](https://www.npmjs.com/products)` + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const result = renderer(markdown) + + expect(result.html).toContain('href="https://www.npmjs.com/products"') + }) + + it('redirects npmjs.org urls to local', async () => { + const markdown = `[Some npmjs.org link](https://www.npmjs.org/package/test-pkg)` + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const result = renderer(markdown) + + expect(result.html).toContain('href="/package/test-pkg"') + }) + + it('redirects npmjs.org urls to local (no www and http)', async () => { + const markdown = `[Some npmjs.org link](http://npmjs.org/package/test-pkg)` + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const result = renderer(markdown) + + expect(result.html).toContain('href="/package/test-pkg"') + }) + }) +}) + +describe('Heading & toc resolution', () => { + describe('for markdown.md headings', () => { + it('should resolve heading starting from h2 & return to h3 at depth 2 correctly', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `# Vue +##v3 +###v3.5 +#### v3.5.33 +##### Features +###### Notes +##v2 +###v2.7 +#### v2.7.15 +##### Bug fixes +###### Notes` + const result = renderer(markdown) + + expect(result.html) + .toBe(`

Vue

+

v3

+

v3.5

+
v3.5.33
+
Features
+
Notes
+

v2

+

v2.7

+
v2.7.15
+
Bug fixes
+
Notes
+`) + expect(result.toc).toEqual([ + { + depth: 1, + id: 'user-content-vue', + text: 'Vue', + }, + { + depth: 2, + id: 'user-content-v3', + text: 'v3', + }, + { + depth: 3, + id: 'user-content-v35', + text: 'v3.5', + }, + { + depth: 4, + id: 'user-content-v3533', + text: 'v3.5.33', + }, + { + depth: 5, + id: 'user-content-features', + text: 'Features', + }, + { + depth: 6, + id: 'user-content-notes', + text: 'Notes', + }, + { + depth: 2, + id: 'user-content-v2', + text: 'v2', + }, + { + depth: 3, + id: 'user-content-v27', + text: 'v2.7', + }, + { + depth: 4, + id: 'user-content-v2715', + text: 'v2.7.15', + }, + { + depth: 5, + id: 'user-content-bug-fixes', + text: 'Bug fixes', + }, + { + depth: 6, + id: 'user-content-notes-1', + text: 'Notes', + }, + ]) + }) + }) + + describe('for releases headings', () => { + it('should resolve heading starting from h3 & return to h4 at depth 2 correctly', async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = `# Vue +##v3 +###v3.5 +#### v3.5.33 +##### Features +###### Notes +##v2 +###v2.7 +#### v2.7.15 +##### Bug fixes +###### Notes` + const result = renderer(markdown, 123456789) + + expect(result.html) + .toBe(`

Vue

+

v3

+
v3.5
+
v3.5.33
+
Features
+
Notes
+

v2

+
v2.7
+
v2.7.15
+
Bug fixes
+
Notes
+`) + expect(result.toc).toEqual([ + { + depth: 1, + id: 'user-content-123456789-vue', + text: 'Vue', + }, + { + depth: 2, + id: 'user-content-123456789-v3', + text: 'v3', + }, + { + depth: 3, + id: 'user-content-123456789-v35', + text: 'v3.5', + }, + { + depth: 4, + id: 'user-content-123456789-v3533', + text: 'v3.5.33', + }, + { + depth: 5, + id: 'user-content-123456789-features', + text: 'Features', + }, + { + depth: 6, + id: 'user-content-123456789-notes', + text: 'Notes', + }, + { + depth: 2, + id: 'user-content-123456789-v2', + text: 'v2', + }, + { + depth: 3, + id: 'user-content-123456789-v27', + text: 'v2.7', + }, + { + depth: 4, + id: 'user-content-123456789-v2715', + text: 'v2.7.15', + }, + { + depth: 5, + id: 'user-content-123456789-bug-fixes', + text: 'Bug fixes', + }, + { + depth: 6, + id: 'user-content-123456789-notes-1', + text: 'Notes', + }, + ]) + }) + }) + + it("shouldn't resolve package@version to an email", async () => { + const info = changelogMdInfoWithPath() + const renderer = await changelogRenderer(info) + const markdown = '## test-pkg@1.0.0' + const result = renderer(markdown) + + expect(result.html).toBe( + '

test-pkg@1.0.0

\n', + ) + }) +}) + +describe('ATX heading #issue/#pr exemption', () => { + it("shouldn't turn issues/PRs into headings", async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `#2869 hello + +#2717 world` + + const result = renderer(markdown) + expect(result.html).toBe('

#2869 hello

\n

#2717 world

\n') + }) + + it("shouldn't turn issues/PRs in list into headings", async () => { + const info = changelogMdinfo() + const renderer = await changelogRenderer(info) + const markdown = `- #2869 hello +- #2717 world` + + const result = renderer(markdown) + expect(result.html).toBe('
    \n
  • #2869 hello
  • \n
  • #2717 world
  • \n
\n') + }) +}) diff --git a/test/unit/server/utils/code-highlight.spec.ts b/test/unit/server/utils/code-highlight.spec.ts index c57edef1e2..6408b13288 100644 --- a/test/unit/server/utils/code-highlight.spec.ts +++ b/test/unit/server/utils/code-highlight.spec.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from 'vitest' -import { linkifyModuleSpecifiers } from '#server/utils/code-highlight' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + getLanguageFromPath, + highlightCode, + linkifyModuleSpecifiers, +} from '#server/utils/code-highlight' + +const mockGetShikiHighlighter = vi.fn() + +beforeAll(() => { + vi.stubGlobal('escapeRawGt', (html: string) => html) + vi.stubGlobal('getShikiHighlighter', mockGetShikiHighlighter) +}) describe('linkifyModuleSpecifiers', () => { const dependencies = { @@ -38,4 +49,226 @@ describe('linkifyModuleSpecifiers', () => { '', ) }) + + it('prefers file-aware resolver for self package subpath imports', () => { + const html = + '' + + 'import' + + ' * as walk ' + + 'from' + + ' "empathic/walk"' + + '' + + const result = linkifyModuleSpecifiers(html, { + dependencies, + resolveRelative: specifier => + specifier.includes('empathic/walk') ? '/package-code/empathic/v/2.0.0/walk.mjs' : null, + }) + + expect(result).toContain( + '"empathic/walk"', + ) + }) + + it('linkifies when spacing around "from" varies across tokens', () => { + const html = + '' + + 'import' + + ' * as walk ' + + 'from ' + + '"empathic/walk"' + + '' + + const result = linkifyModuleSpecifiers(html, { + resolveRelative: specifier => + specifier.includes('empathic/walk') ? '/package-code/empathic/v/2.0.0/walk.mjs' : null, + }) + + expect(result).toContain( + '"empathic/walk"', + ) + }) + + it('falls back to dependency links when the file-aware resolver cannot resolve a specifier', () => { + const html = + '' + + 'import' + + ' "vue"' + + '' + + const result = linkifyModuleSpecifiers(html, { + dependencies, + resolveRelative: () => null, + }) + + expect(result).toContain('"vue"') + }) + + it('linkifies side-effect imports even when import token spacing varies', () => { + const html = + '' + + ' import ' + + ' "@unocss/webpack" ' + + '' + + const result = linkifyModuleSpecifiers(html, { dependencies }) + + expect(result).toContain( + '"@unocss/webpack"', + ) + }) + + it('does not link Node built-ins or package specifiers when they are not dependencies', () => { + const htmlFrom = `from "fs"` + const htmlSide = + ' import ' + + ' "node:path" ' + + expect(linkifyModuleSpecifiers(htmlFrom, { dependencies: {} })).toBe(htmlFrom) + expect(linkifyModuleSpecifiers(htmlSide, { dependencies: {} })).toBe(htmlSide) + + const unknown = + 'from' + + ' "not-in-deps"' + const linked = linkifyModuleSpecifiers(unknown, { dependencies: {} }) + expect(linked).toContain('"not-in-deps"') + }) + + it('linkifies require() and dynamic import() calls', () => { + const requireHtml = + '' + + 'require' + + '(' + + '\'vue\'' + + '' + + const dynImportHtml = + '' + + 'import' + + '(' + + '"vue"' + + '' + + const deps = { vue: { version: '3.4.0' } } + + expect(linkifyModuleSpecifiers(requireHtml, { dependencies: deps })).toContain( + '\'vue\'', + ) + expect(linkifyModuleSpecifiers(dynImportHtml, { dependencies: deps })).toContain( + '"vue"', + ) + }) + + it('still links import() when the keyword is split across adjacent spans (e.g. dyn+import)', () => { + const html = + '' + + 'dynimport' + + '(' + + "'vue'" + + '' + + const linked = linkifyModuleSpecifiers(html, { dependencies: { vue: { version: '3.4.0' } } }) + expect(linked).toContain('') + }) +}) + +describe('getLanguageFromPath', () => { + it('prefers well-known filenames over extension heuristics', () => { + expect(getLanguageFromPath('foo/README.md')).toBe('markdown') + expect(getLanguageFromPath('nested/tsconfig.json')).toBe('jsonc') + expect(getLanguageFromPath('.gitignore')).toBe('bash') + expect(getLanguageFromPath('pnpm-lock.yaml')).toBe('yaml') + }) + + it('maps extensions and falls back to plain text', () => { + expect(getLanguageFromPath('src/a.ts')).toBe('typescript') + expect(getLanguageFromPath('src/b.vue')).toBe('vue') + expect(getLanguageFromPath('notes.mdx')).toBe('markdown') + expect(getLanguageFromPath('weird.unknownext')).toBe('text') + }) +}) + +describe('highlightCode', () => { + afterEach(() => { + mockGetShikiHighlighter.mockReset() + }) + + it('collapses newline gaps between Shiki line spans', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['typescript'], + codeToHtml: () => + '
const x = 1;\nconsole.log(x)
', + }) + + const html = await highlightCode('const x = 1;\nconsole.log(x)', 'typescript') + expect(html).not.toContain('\n') + expect(html).toContain('') + }) + + it('wraps lines manually when Shiki output omits .line spans', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['typescript'], + codeToHtml: () => + '
line-a\nline-b
', + }) + + const html = await highlightCode('line-a\nline-b', 'typescript') + expect(html).toContain('line-a') + expect(html).toContain('line-b') + }) + + it('runs linkify when the loaded language is import-aware', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['javascript'], + codeToHtml: () => + '
' +
+        '' +
+        'import foo from "vue"' +
+        '
', + }) + + const html = await highlightCode('import foo from "vue"', 'javascript', { + dependencies: { vue: { version: '3.4.0' } }, + }) + expect(html).toContain('/package-code/vue/v/3.4.0') + }) + + it('does not treat markdown as an import-linking language', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['markdown'], + codeToHtml: () => + '
' +
+        'import x from "vue"' +
+        '
', + }) + + const html = await highlightCode('import x from "vue"', 'markdown', { + dependencies: { vue: { version: '3.4.0' } }, + }) + expect(html).not.toContain('import-link') + }) + + it('falls back to escaped wrappers when the language is not loaded', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['typescript'], + codeToHtml: () => '', + }) + + const html = await highlightCode('a < b', 'not-a-real-lang', {}) + expect(html).toContain('<') + expect(html).toContain('') + expect(html).toContain('github-dark') + }) + + it('falls back when Shiki throws while highlighting', async () => { + mockGetShikiHighlighter.mockResolvedValue({ + getLoadedLanguages: () => ['typescript'], + codeToHtml: () => { + throw new Error('shiki failed') + }, + }) + + const html = await highlightCode('hello', 'typescript', {}) + expect(html).toContain('hello') + }) }) diff --git a/test/unit/server/utils/download-evolution.spec.ts b/test/unit/server/utils/download-evolution.spec.ts new file mode 100644 index 0000000000..85a6400b6e --- /dev/null +++ b/test/unit/server/utils/download-evolution.spec.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EvolutionOptions } from '~/types/chart' +import { + buildDailyEvolution, + buildMonthlyEvolution, + buildWeeklyEvolution, + buildYearlyEvolution, +} from '~/utils/chart-data-buckets' +import { fetchDownloadsEvolution } from '#server/utils/download-evolution' + +vi.mock('~/utils/chart-data-buckets', () => ({ + buildDailyEvolution: vi.fn(), + buildMonthlyEvolution: vi.fn(), + buildWeeklyEvolution: vi.fn(), + buildYearlyEvolution: vi.fn(), +})) + +const buildDailyEvolutionMock = vi.mocked(buildDailyEvolution) +const buildMonthlyEvolutionMock = vi.mocked(buildMonthlyEvolution) +const buildWeeklyEvolutionMock = vi.mocked(buildWeeklyEvolution) +const buildYearlyEvolutionMock = vi.mocked(buildYearlyEvolution) + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z')) + + vi.stubGlobal( + '$fetch', + vi.fn(async () => ({ + downloads: [ + { day: '2026-05-30', downloads: 30 }, + { day: '2026-05-29', downloads: 20 }, + { day: '2026-05-31', downloads: 40 }, + ], + })), + ) + + buildDailyEvolutionMock.mockReturnValue('daily-result' as never) + buildWeeklyEvolutionMock.mockReturnValue('weekly-result' as never) + buildMonthlyEvolutionMock.mockReturnValue('monthly-result' as never) + buildYearlyEvolutionMock.mockReturnValue('yearly-result' as never) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('fetchDownloadsEvolution', () => { + it('fetches the last 30 days by default and builds daily evolution', async () => { + const result = await fetchDownloadsEvolution('vue', { + granularity: 'day', + } as EvolutionOptions) + + expect(result).toBe('daily-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-02:2026-05-31/vue', + ) + expect(buildDailyEvolutionMock).toHaveBeenCalledWith([ + { day: '2026-05-29', value: 20 }, + { day: '2026-05-30', value: 30 }, + { day: '2026-05-31', value: 40 }, + ]) + }) + + it('uses explicit startDate and endDate when provided', async () => { + const result = await fetchDownloadsEvolution('@scope/pkg', { + granularity: 'week', + startDate: '2026-01-01T10:30:00.000Z', + endDate: '2026-01-31T18:45:00.000Z', + } as EvolutionOptions) + + expect(result).toBe('weekly-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-01-01:2026-01-31/%40scope%2Fpkg', + ) + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + [ + { day: '2026-05-29', value: 20 }, + { day: '2026-05-30', value: 30 }, + { day: '2026-05-31', value: 40 }, + ], + '2026-01-01', + '2026-01-31', + ) + }) + + it('builds monthly evolution from the configured month window', async () => { + const result = await fetchDownloadsEvolution('nuxt', { + granularity: 'month', + months: 3, + } as EvolutionOptions) + + expect(result).toBe('monthly-result') + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-03-01:2026-05-31/nuxt', + ) + expect(buildMonthlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2026-03-01', + '2026-05-31', + ) + }) + + it('builds yearly evolution from the package creation year', async () => { + const result = await fetchDownloadsEvolution( + 'react', + { + granularity: 'year', + } as EvolutionOptions, + '2013-05-29T00:00:00.000Z', + ) + + expect(result).toBe('yearly-result') + expect($fetch).toHaveBeenCalledTimes(10) + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2013-01-01:2014-06-24/react', + ) + expect($fetch).toHaveBeenNthCalledWith( + 10, + 'https://api.npmjs.org/downloads/range/2026-04-23:2026-05-31/react', + ) + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2013-01-01', + '2026-05-31', + ) + }) + + it('splits large ranges into npm-compatible chunks and merges the results', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'year', + startDate: '2024-01-01', + endDate: '2026-05-31', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledTimes(2) + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2024-01-01:2025-06-23/vue', + ) + expect($fetch).toHaveBeenNthCalledWith( + 2, + 'https://api.npmjs.org/downloads/range/2025-06-24:2026-05-31/vue', + ) + }) + + it('accepts ISO datetime strings for startDate and endDate', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'day', + startDate: '2026-05-01T12:34:56.000Z', + endDate: '2026-05-31T23:59:59.999Z', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-01:2026-05-31/vue', + ) + }) + + it('falls back to the default 5-year window for yearly ranges without packageCreatedIso', async () => { + await fetchDownloadsEvolution('react', { + granularity: 'year', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledTimes(4) + + expect($fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.npmjs.org/downloads/range/2021-06-02:2022-11-23/react', + ) + + expect($fetch).toHaveBeenNthCalledWith( + 4, + 'https://api.npmjs.org/downloads/range/2025-11-08:2026-05-31/react', + ) + + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2021-06-02', + '2026-05-31', + ) + }) + + it('uses a 12 month window by default for monthly evolution', async () => { + await fetchDownloadsEvolution('nuxt', { + granularity: 'month', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2025-06-01:2026-05-31/nuxt', + ) + + expect(buildMonthlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2025-06-01', + '2026-05-31', + ) + }) + + it('uses a 52 week window by default for weekly evolution', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'week', + } as EvolutionOptions) + + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2025-06-02', + '2026-05-31', + ) + }) + + it('uses packageCreatedIso when resolving a yearly range without startDate', async () => { + await fetchDownloadsEvolution( + 'react', + { + granularity: 'year', + endDate: '2013-12-31', + } as EvolutionOptions, + '2013-05-29T00:00:00.000Z', + ) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2013-01-01:2013-12-31/react', + ) + + expect(buildYearlyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2013-01-01', + '2013-12-31', + ) + }) + + it('ignores invalid date formats', async () => { + await fetchDownloadsEvolution('vue', { + granularity: 'day', + startDate: 'not-a-date', + } as EvolutionOptions) + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-02:2026-05-31/vue', + ) + }) + + it('uses the configured week window when granularity is week', async () => { + const result = await fetchDownloadsEvolution('vue', { + granularity: 'week', + weeks: 4, + } as EvolutionOptions) + + expect(result).toBe('weekly-result') + + expect($fetch).toHaveBeenCalledWith( + 'https://api.npmjs.org/downloads/range/2026-05-04:2026-05-31/vue', + ) + + expect(buildWeeklyEvolutionMock).toHaveBeenCalledWith( + expect.any(Array), + '2026-05-04', + '2026-05-31', + ) + }) +}) diff --git a/test/unit/server/utils/embed-downloads-svg.spec.ts b/test/unit/server/utils/embed-downloads-svg.spec.ts new file mode 100644 index 0000000000..456758a652 --- /dev/null +++ b/test/unit/server/utils/embed-downloads-svg.spec.ts @@ -0,0 +1,718 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createDownloadsSvgResponse } from '../../../../server/utils/embed-downloads-svg' + +const mocks = vi.hoisted(() => ({ + fetchDownloadsEvolution: vi.fn(), + buildTrendsChartData: vi.fn(), + buildNormalisedTrendsDataset: vi.fn(), + buildTrendsChartConfig: vi.fn(), + resolveEmbedChartColors: vi.fn(), + mergeConfigs: vi.fn(), + createStaticVueUiXy: vi.fn(), + generateWatermarkLogo: vi.fn(), + isLastDayOfMonth: vi.fn(), + getEffectiveEndDateIso: vi.fn(), + isLastDayOfYear: vi.fn(), +})) + +vi.mock('#server/utils/download-evolution', () => ({ + fetchDownloadsEvolution: mocks.fetchDownloadsEvolution, +})) + +vi.mock('#shared/utils/trends-chart', () => ({ + LOCALES_WITH_EXTRA_SPACE: ['fr', 'fr-FR'], + buildTrendsChartData: mocks.buildTrendsChartData, + buildNormalisedTrendsDataset: mocks.buildNormalisedTrendsDataset, + buildTrendsChartConfig: mocks.buildTrendsChartConfig, + generateWatermarkLogo: mocks.generateWatermarkLogo, +})) + +vi.mock('#shared/utils/embed-chart-colors', () => ({ + resolveEmbedChartColors: mocks.resolveEmbedChartColors, +})) + +vi.mock('vue-data-ui/utils', () => ({ + mergeConfigs: mocks.mergeConfigs, +})) + +vi.mock('vue-data-ui/ssr/vue-ui-xy', () => ({ + createStaticVueUiXy: mocks.createStaticVueUiXy, +})) + +vi.mock('~/utils/date', () => ({ + getEffectiveEndDateIso: mocks.getEffectiveEndDateIso, + isLastDayOfMonth: mocks.isLastDayOfMonth, + isLastDayOfYear: mocks.isLastDayOfYear, +})) + +vi.mock('~/utils/colors', () => ({ + OKLCH_NEUTRAL_FALLBACK: 'oklch-neutral-fallback', +})) + +function createEvolution(packageName: string) { + return [ + { + period: '2026-05-01', + downloads: packageName.length * 100, + }, + ] +} + +function createDataset(overrides: Record = {}) { + return [ + { + name: 'vue', + series: [10, 20], + dashIndices: undefined, + ...overrides, + }, + ] +} + +beforeEach(() => { + vi.clearAllMocks() + + mocks.fetchDownloadsEvolution.mockImplementation(async (packageName: string) => + createEvolution(packageName), + ) + + mocks.resolveEmbedChartColors.mockReturnValue({ + fg: '#111111', + bg: '#ffffff', + fgMuted: '#666666', + fgSubtle: '#999999', + }) + + mocks.buildTrendsChartData.mockReturnValue({ + dates: ['2026-05-01', '2026-05-02'], + dataset: createDataset(), + }) + + mocks.buildNormalisedTrendsDataset.mockReturnValue(createDataset()) + + mocks.buildTrendsChartConfig.mockReturnValue({ + chart: { + base: true, + }, + }) + + mocks.mergeConfigs.mockImplementation(({ defaultConfig, userConfig }) => ({ + defaultConfig, + userConfig, + })) + + mocks.generateWatermarkLogo.mockReturnValue('') + mocks.getEffectiveEndDateIso.mockReturnValue('2026-05-31') + mocks.isLastDayOfMonth.mockReturnValue(true) + mocks.isLastDayOfYear.mockReturnValue(true) + + mocks.createStaticVueUiXy.mockImplementation(async options => { + options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 100, + y: 50, + value: 1200, + }, + ], + }, + { + plots: [], + }, + ], + }) + + return '' + }) +}) + +describe('downloads SVG embed response', () => { + it('throws 400 when no valid package name is provided', async () => { + await expect(createDownloadsSvgResponse({})).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Missing package name. Use ?package=nuxt or ?packages=vite,rolldown', + }) + }) + + it('throws 501 for likes metric', async () => { + await expect( + createDownloadsSvgResponse({ + package: 'vue', + metric: 'likes', + }), + ).rejects.toMatchObject({ + statusCode: 501, + }) + }) + + it('throws 501 for contributors metric', async () => { + await expect( + createDownloadsSvgResponse({ + package: 'vue', + metric: 'contributors', + }), + ).rejects.toMatchObject({ + statusCode: 501, + }) + }) + + it('renders an SVG response for a single package', async () => { + const result = await createDownloadsSvgResponse({ + package: 'vue', + }) + + expect(result).toBe('') + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith('vue', { + granularity: 'week', + weeks: 52, + months: 12, + startDate: undefined, + endDate: undefined, + }) + }) + + it('supports multiple packages from the packages query', async () => { + await createDownloadsSvgResponse({ + packages: 'Vue, @Nuxt/Kit, invalid package, React', + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledTimes(3) + expect(mocks.fetchDownloadsEvolution).toHaveBeenNthCalledWith(1, 'vue', expect.any(Object)) + expect(mocks.fetchDownloadsEvolution).toHaveBeenNthCalledWith( + 2, + '@nuxt/kit', + expect.any(Object), + ) + expect(mocks.fetchDownloadsEvolution).toHaveBeenNthCalledWith(3, 'react', expect.any(Object)) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + packageNames: ['vue', '@nuxt/kit', 'react'], + isMultiPackageMode: true, + }), + ) + }) + + it('limits package names to 8 entries', async () => { + await createDownloadsSvgResponse({ + packages: 'a,b,c,d,e,f,g,h,i,j', + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledTimes(8) + }) + + it.each([ + ['daily', 'day', 'daily'], + ['day', 'day', 'daily'], + ['weekly', 'week', 'weekly'], + ['week', 'week', 'weekly'], + ['monthly', 'month', 'monthly'], + ['month', 'month', 'monthly'], + ['yearly', 'year', 'yearly'], + ['year', 'year', 'yearly'], + ])('parses granularity %s', async (queryGranularity, fetchGranularity, chartGranularity) => { + await createDownloadsSvgResponse({ + package: 'vue', + granularity: queryGranularity, + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + granularity: fetchGranularity, + }), + ) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + selectedGranularity: chartGranularity, + displayedGranularity: chartGranularity, + }), + ) + }) + + it('clamps width, height, weeks, and months', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + width: 99999, + height: 1, + weeks: 99999, + months: 0, + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + weeks: 260, + months: 1, + }), + ) + + expect(mocks.mergeConfigs).toHaveBeenCalledWith( + expect.objectContaining({ + userConfig: expect.objectContaining({ + chart: expect.objectContaining({ + width: 1600, + height: 240, + }), + }), + }), + ) + }) + + it('uses fallback dimensions and periods for invalid numeric query values', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + width: 'nope', + height: 'nope', + weeks: 'nope', + months: 'nope', + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + weeks: 52, + months: 12, + }), + ) + + expect(mocks.mergeConfigs).toHaveBeenCalledWith( + expect.objectContaining({ + userConfig: expect.objectContaining({ + chart: expect.objectContaining({ + width: 900, + height: 420, + }), + }), + }), + ) + }) + + it('parses valid dates and ignores invalid dates', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + start: 'invalid', + endDate: '2026-05-31', + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + startDate: undefined, + endDate: '2026-05-31', + }), + ) + }) + + it('uses startDate and end aliases', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + startDate: '2026-01-01', + end: '2026-05-31', + }) + + expect(mocks.fetchDownloadsEvolution).toHaveBeenCalledWith( + 'vue', + expect.objectContaining({ + startDate: '2026-01-01', + endDate: '2026-05-31', + }), + ) + }) + + it('uses dark colors when mode is dark', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + mode: 'dark', + }) + + expect(mocks.resolveEmbedChartColors).toHaveBeenCalledWith('dark') + }) + + it('uses light colors by default', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + expect(mocks.resolveEmbedChartColors).toHaveBeenCalledWith('light') + }) + + it('uses a valid locale', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + locale: 'fr-FR', + }) + + const chartDataOptions = mocks.buildTrendsChartData.mock.calls[0]![0] + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('fr-FR') + }) + + it('falls back to en for invalid locale', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + locale: 'not a locale', + }) + + const chartDataOptions = mocks.buildTrendsChartData.mock.calls[0]![0] + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('en') + }) + + it('uses an identity translation function for chart data', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const chartDataOptions = mocks.buildTrendsChartData.mock.calls[0]![0] + + expect(chartDataOptions.t('downloads')).toBe('downloads') + }) + + it('sanitizes yLabel', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + yLabel: '&"`\u0000', + }) + + const userConfig = mocks.mergeConfigs.mock.calls[0]![0].userConfig + expect(userConfig.chart.grid.labels.axis.yLabel).toBe('Downloads') + }) + + it('uses fallback yLabel for non-string values', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + yLabel: 123, + }) + + const userConfig = mocks.mergeConfigs.mock.calls[0]![0].userConfig + expect(userConfig.chart.grid.labels.axis.yLabel).toBe('') + }) + + it('accepts hex accent colors', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + accent: '#abc', + }) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + accent: '#abc', + }), + ) + }) + + it('accepts oklch accent colors', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + accent: 'oklch(0.787 0.128 230.318)', + }) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch(0.787 0.128 230.318)', + }), + ) + }) + + it('falls back for invalid accent colors', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + accent: 'red', + }) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch-neutral-fallback', + }), + ) + }) + + it('falls back for non-string accent colors', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + accent: 42, + }) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + accent: 'oklch-neutral-fallback', + }), + ) + }) + + it('throws 404 when chart dataset is empty', async () => { + mocks.buildTrendsChartData.mockReturnValue({ + dates: [], + dataset: [], + }) + + await expect( + createDownloadsSvgResponse({ + package: 'vue', + }), + ).rejects.toMatchObject({ + statusCode: 404, + statusMessage: 'No chart dataset generated', + }) + }) + + it('throws 404 when normalized dataset is empty', async () => { + mocks.buildNormalisedTrendsDataset.mockReturnValue([]) + + await expect( + createDownloadsSvgResponse({ + package: 'vue', + }), + ).rejects.toMatchObject({ + statusCode: 404, + statusMessage: 'No normalized dataset generated', + }) + }) + + it('adds a dash index to the last monthly point when the effective end date is not the last day of month', async () => { + mocks.isLastDayOfMonth.mockReturnValue(false) + mocks.getEffectiveEndDateIso.mockReturnValue('2026-05-12') + mocks.buildNormalisedTrendsDataset.mockReturnValue([ + { + name: 'vue', + series: [10, 20, 30], + dashIndices: [0, 2], + }, + ]) + + await createDownloadsSvgResponse({ + package: 'vue', + granularity: 'month', + endDate: '2026-05-12', + }) + + expect(mocks.createStaticVueUiXy).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [0, 2], + }), + ], + }), + ) + }) + + it('filters negative dash index for empty monthly series', async () => { + mocks.isLastDayOfMonth.mockReturnValue(false) + mocks.buildNormalisedTrendsDataset.mockReturnValue([ + { + name: 'vue', + series: [], + }, + ]) + + await createDownloadsSvgResponse({ + package: 'vue', + granularity: 'month', + }) + + expect(mocks.createStaticVueUiXy).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [], + }), + ], + }), + ) + }) + + it('keeps dash indices unchanged outside incomplete monthly data', async () => { + mocks.buildNormalisedTrendsDataset.mockReturnValue([ + { + name: 'vue', + series: [10, 20], + dashIndices: [1], + }, + ]) + + await createDownloadsSvgResponse({ + package: 'vue', + }) + + expect(mocks.createStaticVueUiXy).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: [ + expect.objectContaining({ + dashIndices: [1], + }), + ], + }), + ) + }) + + it('generates extra SVG labels and watermark content', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const options = mocks.createStaticVueUiXy.mock.calls[0]![0] + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 100, + y: 50, + value: 1200, + }, + ], + }, + { + plots: [], + }, + ], + }) + + expect(content).toContain('') + expect(mocks.generateWatermarkLogo).toHaveBeenCalledWith({ + x: 12, + y: 360, + width: 80, + height: 30, + fill: '#999999', + }) + }) + + it('falls back to an empty singleEvolution when the first package has no evolution', async () => { + mocks.fetchDownloadsEvolution.mockImplementation(async (packageName: string) => { + if (packageName === 'vue') { + return undefined + } + + return createEvolution(packageName) + }) + + await createDownloadsSvgResponse({ + package: 'vue', + }) + + expect(mocks.buildTrendsChartData).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvolution: [], + }), + ) + + expect(mocks.buildTrendsChartConfig).toHaveBeenCalledWith( + expect.objectContaining({ + singleEvolution: [], + }), + ) + }) + + it('uses an identity translation function for chart config', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const chartConfigOptions = mocks.buildTrendsChartConfig.mock.calls[0]![0] + + expect(chartConfigOptions.t('downloads')).toBe('downloads') + }) + + it('formats the last plot value in additionalSvgContent', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const options = mocks.createStaticVueUiXy.mock.calls[0]![0] + + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 10, + y: 20, + value: 1234, + }, + ], + }, + ], + }) + + expect(content).toContain('1.2K') + }) + + it('falls back to 0 when the last plot value is missing', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const options = mocks.createStaticVueUiXy.mock.calls[0]![0] + + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: [ + { + x: 10, + y: 20, + value: undefined, + }, + ], + }, + ], + }) + + expect(content).toContain('0') + }) + + it('falls back to en when canonical locales returns an empty array', async () => { + const spy = vi.spyOn(Intl, 'getCanonicalLocales').mockReturnValue([]) + + await createDownloadsSvgResponse({ + package: 'vue', + locale: 'fr', + }) + + const chartDataOptions = mocks.buildTrendsChartData.mock.calls[0]![0] + + expect(chartDataOptions.compactNumberFormatter.resolvedOptions().locale).toBe('en') + + spy.mockRestore() + }) + + it('handles series without plots', async () => { + await createDownloadsSvgResponse({ + package: 'vue', + }) + + const options = mocks.createStaticVueUiXy.mock.calls[0]![0] + + const content = options.additionalSvgContent({ + drawingArea: { + bottom: 300, + }, + series: [ + { + plots: undefined, + }, + ], + }) + + expect(content).toContain('') + expect(content).not.toContain(' vi.fn()) + +vi.mock('node:dns/promises', () => ({ + lookup: dnsLookupMock, +})) + import { isTrustedImageDomain, isAllowedImageUrl, @@ -122,13 +129,17 @@ describe('Image Proxy Utils', () => { }) describe('resolveAndValidateHost', () => { + afterEach(() => { + dnsLookupMock.mockReset() + }) + it('allows URLs with publicly-resolvable hostnames', async () => { - // example.com resolves to a public IP + dnsLookupMock.mockResolvedValue([{ address: '93.184.215.14', family: 4 }]) expect(await resolveAndValidateHost('https://example.com/image.png')).toBe(true) }) it('blocks URLs with hostnames that resolve to loopback', async () => { - // localhost resolves to 127.0.0.1 + dnsLookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) expect(await resolveAndValidateHost('http://localhost/image.png')).toBe(false) }) @@ -143,6 +154,7 @@ describe('Image Proxy Utils', () => { }) it('blocks hostnames that fail DNS resolution', async () => { + dnsLookupMock.mockRejectedValue(new Error('ENOTFOUND')) expect( await resolveAndValidateHost( 'http://this-domain-definitely-does-not-exist.invalid/img.png', diff --git a/test/unit/server/utils/import-resolver.spec.ts b/test/unit/server/utils/import-resolver.spec.ts index 6bf0a4f8c2..7f872d5b25 100644 --- a/test/unit/server/utils/import-resolver.spec.ts +++ b/test/unit/server/utils/import-resolver.spec.ts @@ -3,7 +3,11 @@ import type { PackageFileTree } from '#shared/types' import { createImportResolver, flattenFileTree, + resolveAliasToDir, + resolvePackageSelfImport, + resolveInternalImport, resolveRelativeImport, + type InternalImportTarget, } from '#server/utils/import-resolver' describe('flattenFileTree', () => { @@ -48,6 +52,55 @@ describe('flattenFileTree', () => { expect(files.has('index.js')).toBe(true) expect(files.has('cli.js')).toBe(true) }) + + it('ignores directory nodes without children', () => { + const tree: PackageFileTree[] = [ + { name: 'empty', path: 'empty', type: 'directory', children: undefined }, + { name: 'readme.md', path: 'readme.md', type: 'file', size: 1 }, + ] + + const files = flattenFileTree(tree) + + expect(files.has('readme.md')).toBe(true) + expect(files.has('empty')).toBe(false) + }) +}) + +describe('resolveAliasToDir', () => { + it('returns the deepest matching alias directory', () => { + expect(resolveAliasToDir('#app', './src/app/generated/app/index.js')).toBe( + './src/app/generated/app', + ) + }) + + it('returns the full path for root aliases', () => { + expect(resolveAliasToDir('#', './src/app/index.js')).toBe('./src/app/index.js') + }) + + it('returns null when the alias does not match a path segment', () => { + expect(resolveAliasToDir('#components', './src/app/index.js')).toBeNull() + }) + + it('returns null for unsupported alias prefixes', () => { + expect(resolveAliasToDir('components', './src/components/index.js')).toBeNull() + }) + + it('returns null when filePath is missing', () => { + expect(resolveAliasToDir('#app', null)).toBeNull() + expect(resolveAliasToDir('#app', undefined)).toBeNull() + }) + + it('normalizes #/foo style aliases', () => { + expect(resolveAliasToDir('#/dist', 'root/dist/pkg/index.js')).toBe('root/dist') + }) + + it('normalizes $/foo style aliases', () => { + expect(resolveAliasToDir('$/dist', 'root/dist/pkg/index.js')).toBe('root/dist') + }) + + it('returns null when the file path trims to empty', () => { + expect(resolveAliasToDir('#', '///')).toBeNull() + }) }) describe('resolveRelativeImport', () => { @@ -148,6 +201,27 @@ describe('resolveRelativeImport', () => { expect(resolved).toBeNull() }) + + it('uses default extension priority for non-js/ts sources such as .vue', () => { + const files = new Set(['src/helper.ts', 'src/helper.js']) + const resolved = resolveRelativeImport('./helper', 'src/Component.vue', files) + + expect(resolved?.path).toBe('src/helper.ts') + }) + + it('prefers declaration peers when resolving from .d.mts', () => { + const files = new Set(['types/mod.d.mts']) + const resolved = resolveRelativeImport('./mod', 'types/index.d.mts', files) + + expect(resolved?.path).toBe('types/mod.d.mts') + }) + + it('resolves jsx shims when matching a tsx source and only jsx exists on disk', () => { + const files = new Set(['ui/Box.jsx']) + const resolved = resolveRelativeImport('./Box', 'ui/App.tsx', files) + + expect(resolved?.path).toBe('ui/Box.jsx') + }) }) describe('createImportResolver', () => { @@ -177,4 +251,415 @@ describe('createImportResolver', () => { expect(url).toBe('/package-code/@scope/pkg/v/1.2.3/dist/utils.js') }) + + it('resolves package imports aliases to code browser URLs', () => { + const files = new Set(['dist/app/nuxt.js']) + const resolver = createImportResolver(files, 'dist/index.js', 'nuxt', '4.3.1', { + '#app/nuxt': './dist/app/nuxt.js', + }) + + const url = resolver('#app/nuxt') + + expect(url).toBe('/package-code/nuxt/v/4.3.1/dist/app/nuxt.js') + }) + + it('resolves self package subpath imports to code browser URLs', () => { + const files = new Set(['find.mjs', 'walk.mjs']) + const resolver = createImportResolver(files, 'find.mjs', 'empathic', '2.0.0', undefined, { + './walk': { import: './walk.mjs' }, + }) + + const url = resolver('empathic/walk') + + expect(url).toBe('/package-code/empathic/v/2.0.0/walk.mjs') + }) +}) + +describe('resolveInternalImport', () => { + it('resolves exact imports map matches to files in the package', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': './dist/app/nuxt.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/nuxt.js') + }) + + it('supports import condition objects', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': { import: './dist/app/nuxt.js' }, + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/nuxt.js') + }) + + it('returns null when the target file does not exist', () => { + const files = new Set(['dist/app/index.js']) + + const resolved = resolveInternalImport( + '#app/nuxt', + 'dist/index.js', + { + '#app/nuxt': './dist/app/nuxt.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('resolves prefix matches with extension resolution via guessInternalImportTarget', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '#app/components/button.js', + 'dist/index.js', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves file that could not found in the files', () => { + const files = new Set(['dist/app/index.js']) + + const resolved = resolveInternalImport( + '#app/components/button.js', + 'dist/index.js', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('resolves file that prefix is "~/"', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '~/app/components/button.js', + 'dist/index.js', + { + '~/app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves file that prefix is "@/"', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '@/app/components/button.js', + 'dist/index.js', + { + '@/app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves file that prefix is "$/"', () => { + const files = new Set(['dist/app/components/button.js']) + + const resolved = resolveInternalImport( + '$/app/components/button.js', + 'dist/index.js', + { + '$/app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/button.js') + }) + + it('resolves guessed alias targets to directory index files', () => { + const files = new Set(['dist/app/components/index.js']) + + const resolved = resolveInternalImport( + '#app/components', + 'dist/index.js', + { + '#app': './dist/app/index.js', + }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/index.js') + }) + + it('infers extensions for exact import map targets without a file suffix', () => { + const files = new Set(['src/a.ts']) + + const resolved = resolveInternalImport('#token', 'index.ts', { '#token': './src/a' }, files) + + expect(resolved?.path).toBe('src/a.ts') + }) + + it('returns null when imports map is missing', () => { + const files = new Set(['dist/a.js']) + + expect(resolveInternalImport('#x', 'dist/index.js', undefined, files)).toBeNull() + }) + + it('returns null when specifier is not an internal alias style', () => { + const files = new Set(['dist/a.js']) + + expect( + resolveInternalImport('lodash', 'dist/index.js', { '#a': './dist/a.js' }, files), + ).toBeNull() + }) + + it('returns null when the mapped target is not package-relative', () => { + const files = new Set([]) + + const resolved = resolveInternalImport( + '#pkg', + 'index.js', + { '#pkg': '/absolute/outside.js' }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('returns null for guessed paths with extension-like segments that do not exist', () => { + const files = new Set(['dist/app/index.js']) + + const resolved = resolveInternalImport( + '#app/missing.vue', + 'dist/index.js', + { '#app': './dist/app/index.js' }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('strips quotes from internal specifiers before resolving', () => { + const files = new Set(['dist/app/nuxt.js']) + + const resolved = resolveInternalImport( + "'#app/nuxt'", + 'dist/index.js', + { '#app/nuxt': './dist/app/nuxt.js' }, + files, + ) + + expect(resolved?.path).toBe('dist/app/nuxt.js') + }) + + it('falls back to default import condition when import field is absent', () => { + const files = new Set(['dist/legacy.js']) + + const resolved = resolveInternalImport( + '#legacy', + 'dist/index.js', + { '#legacy': { default: './dist/legacy.js' } }, + files, + ) + + expect(resolved?.path).toBe('dist/legacy.js') + }) + + it('ignores non-string import map entries', () => { + const files = new Set(['dist/a.js']) + + const resolved = resolveInternalImport( + '#bad', + 'dist/index.js', + { '#bad': { import: 1 } as unknown as InternalImportTarget }, + files, + ) + + expect(resolved).toBeNull() + }) + + it('resolves #/ slash-variant specifier against a plain #app imports key', () => { + const files = new Set(['dist/app/components/Button.vue']) + + const resolved = resolveInternalImport( + '#/app/components/Button.vue', + 'dist/index.js', + { '#app': './dist/app/index.js' }, + files, + ) + + expect(resolved?.path).toBe('dist/app/components/Button.vue') + }) +}) + +describe('resolvePackageSelfImport', () => { + it('resolves the package root using the dot export', () => { + const files = new Set(['index.mjs']) + + const resolved = resolvePackageSelfImport( + 'empathic', + 'empathic', + { + '.': { import: './index.mjs' }, + }, + 'find.mjs', + files, + ) + + expect(resolved?.path).toBe('index.mjs') + }) + + it('resolves package self subpath imports using exports', () => { + const files = new Set(['find.mjs', 'walk.mjs']) + + const resolved = resolvePackageSelfImport( + 'empathic/walk', + 'empathic', + { + './walk': { import: './walk.mjs' }, + }, + 'find.mjs', + files, + ) + + expect(resolved?.path).toBe('walk.mjs') + }) + + it('resolves package self subpath imports to directory index files', () => { + const files = new Set(['walk/index.mjs']) + + const resolved = resolvePackageSelfImport( + 'empathic/walk', + 'empathic', + { + './walk': { import: './walk' }, + }, + 'find.mjs', + files, + ) + + expect(resolved?.path).toBe('walk/index.mjs') + }) + + it('returns null when the specifier is not for the current package', () => { + const files = new Set(['walk.mjs']) + + const resolved = resolvePackageSelfImport( + 'other-package/walk', + 'empathic', + { + './walk': { import: './walk.mjs' }, + }, + 'find.mjs', + files, + ) + + expect(resolved).toBeNull() + }) + + it('falls back to file-tree based self subpath resolution when exports are unavailable', () => { + const files = new Set(['find.mjs', 'walk.mjs']) + + const resolved = resolvePackageSelfImport( + 'empathic/walk', + 'empathic', + undefined, + 'find.mjs', + files, + ) + + expect(resolved?.path).toBe('walk.mjs') + }) + + it('returns null when neither exports nor fallback resolution can find a file', () => { + const files = new Set(['find.mjs']) + + const resolved = resolvePackageSelfImport( + 'empathic/missing', + 'empathic', + { + './missing': { import: './missing' }, + }, + 'find.mjs', + files, + ) + + expect(resolved).toBeNull() + }) + + it('uses the require export condition when import/default are absent', () => { + const files = new Set(['lib.node.cjs']) + + const resolved = resolvePackageSelfImport( + 'pkg/native', + 'pkg', + { './native': { require: './lib.node.cjs' } }, + 'index.js', + files, + ) + + expect(resolved?.path).toBe('lib.node.cjs') + }) + + it('uses the types export condition as a last resort', () => { + const files = new Set(['types.d.ts']) + + const resolved = resolvePackageSelfImport( + 'pkg/types', + 'pkg', + { './types': { types: './types.d.ts' } }, + 'index.d.ts', + files, + ) + + expect(resolved?.path).toBe('types.d.ts') + }) + + it('returns null when resolvePath rejects unsafe targets', () => { + const files = new Set(['secret.js']) + + const resolved = resolvePackageSelfImport( + 'pkg/leak', + 'pkg', + { './leak': { import: '../secret.js' } }, + 'index.js', + files, + ) + + expect(resolved).toBeNull() + }) + + it('strips quotes before normalizing self-import specifiers', () => { + const files = new Set(['walk.mjs']) + + const resolved = resolvePackageSelfImport( + "'empathic/walk'", + 'empathic', + { './walk': { import: './walk.mjs' } }, + 'find.mjs', + files, + ) + + expect(resolved?.path).toBe('walk.mjs') + }) }) diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index dacc2653b7..d24158d777 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -592,13 +592,13 @@ describe('HTML output', () => { }) describe('heading anchors (renderer.heading)', () => { - it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => { + it('keeps the full-line anchor wrapper and places the link to the heading at the end', async () => { const markdown = '##
My Section' const result = await renderReadmeHtml(markdown, 'test-pkg') expect(result.toc).toEqual([{ text: 'My Section', depth: 2, id: 'user-content-my-section' }]) expect(result.html).toBe( - `

My Section

\n`, + `

My Section

\n`, ) }) @@ -610,7 +610,7 @@ describe('HTML output', () => { { text: 'See docs for more', depth: 3, id: 'user-content-see-docs-for-more' }, ]) expect(result.html).toBe( - `

See docs for more

\n`, + `

See docs for more

\n`, ) }) @@ -620,7 +620,7 @@ describe('HTML output', () => { expect(result.toc).toEqual([{ text: 'Guide: page', depth: 2, id: 'user-content-guide-page' }]) expect(result.html).toBe( - '

Guide: page

', + '

Guide: page

', ) }) }) diff --git a/test/unit/shared/utils/diff.spec.ts b/test/unit/shared/utils/diff.spec.ts new file mode 100644 index 0000000000..bdeecb2318 --- /dev/null +++ b/test/unit/shared/utils/diff.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { truncateDiffHunks } from '#shared/utils/diff' +import type { DiffHunk, DiffSkipBlock } from '#shared/types/compare' + +function createHunk(lineCount: number): DiffHunk { + return { + type: 'hunk', + content: '@@ -1,1 +1,1 @@', + oldStart: 1, + oldLines: lineCount, + newStart: 1, + newLines: lineCount, + lines: Array.from({ length: lineCount }, (_, index) => ({ + type: 'normal', + oldLineNumber: index + 1, + newLineNumber: index + 1, + content: [{ value: `line ${index + 1}`, type: 'normal' }], + })), + } +} + +describe('truncateDiffHunks', () => { + it('leaves hunks untouched when they fit within the line budget', () => { + const hunk = createHunk(2) + + const result = truncateDiffHunks([hunk], 3) + + expect(result.truncated).toBe(false) + expect(result.hunks).toEqual([hunk]) + }) + + it('truncates hunk lines when the line budget is exceeded', () => { + const result = truncateDiffHunks([createHunk(4)], 2) + + expect(result.truncated).toBe(true) + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]?.type).toBe('hunk') + expect((result.hunks[0] as DiffHunk).lines).toHaveLength(2) + }) + + it('does not count skip blocks against the line budget', () => { + const skip: DiffSkipBlock = { + type: 'skip', + count: 10, + content: '10 lines hidden', + } + + const result = truncateDiffHunks([skip, createHunk(2)], 1) + + expect(result.truncated).toBe(true) + expect(result.hunks[0]).toBe(skip) + expect((result.hunks[1] as DiffHunk).lines).toHaveLength(1) + }) + + it('does not append a skip block after the line budget is exhausted', () => { + const skip: DiffSkipBlock = { + type: 'skip', + count: 10, + content: '10 lines hidden', + } + const hunk = createHunk(1) + + const result = truncateDiffHunks([hunk, skip], 1) + + expect(result.truncated).toBe(true) + expect(result.hunks).toEqual([hunk]) + }) +}) diff --git a/test/unit/shared/utils/download-chart-last-label.spec.ts b/test/unit/shared/utils/download-chart-last-label.spec.ts new file mode 100644 index 0000000000..1923883816 --- /dev/null +++ b/test/unit/shared/utils/download-chart-last-label.spec.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest' +import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label' + +const colors = { + foreground: '#000000', + background: '#FFFFFF', + fallbackSerieColor: '#999999', +} + +describe('createLastDatapointLabelsSvg', () => { + it('returns an empty string when there are no series', () => { + const result = createLastDatapointLabelsSvg({ + series: [], + drawingArea: { top: 10, height: 100 }, + colors, + formatValue: value => String(value), + isDarkMode: false, + }) + + expect(result).toBe('') + }) + + it('returns an empty string when no serie has plots', () => { + const result = createLastDatapointLabelsSvg({ + series: [{ color: '#FF0000' }, { color: '#00FF00', plots: [] }], + drawingArea: { top: 10, height: 100 }, + colors, + formatValue: value => String(value), + isDarkMode: false, + }) + + expect(result).toBe('') + }) + + it('renders regular labels when there is no collision', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 20, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 90, value: 20 }] }, + ], + drawingArea: { top: 0, height: 120 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain(' { + const result = createLastDatapointLabelsSvg({ + series: [{ plots: [{ x: 10, y: 20, value: 42 }] }], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + fontSize: 12, + labelOffset: 10, + }) + + expect(result).toContain('x="20"') + expect(result).toContain('font-size="12"') + expect(result).toContain('fill="#000000"') + expect(result).toContain('stroke="#FFFFFF"') + }) + + it('uses only the last plot of each serie', () => { + const formatValue = vi.fn((value: number) => `${value}`) + + const result = createLastDatapointLabelsSvg({ + series: [ + { + color: '#FF0000', + plots: [ + { x: 10, y: 10, value: 1 }, + { x: 50, y: 50, value: 99 }, + ], + }, + ], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue, + isDarkMode: false, + }) + + expect(formatValue).toHaveBeenCalledTimes(1) + expect(formatValue).toHaveBeenCalledWith(99) + expect(result).toContain('x="74"') + expect(result).toContain('y="50"') + expect(result).not.toContain('x="34"') + }) + + it('formats safe numeric values and falls back to zero for invalid values', () => { + const formatValue = vi.fn((value: number) => `value:${value}`) + + const result = createLastDatapointLabelsSvg({ + series: [{ plots: [{ x: 10, y: 20, value: Number.NaN }] }], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue, + isDarkMode: false, + }) + + expect(formatValue).toHaveBeenCalledWith(0) + expect(result).toContain('value:0') + }) + + it('renders collision labels as a compact non-overlapping label rack', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 50, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 55, value: 30 }] }, + { color: '#0000FF', plots: [{ x: 100, y: 60, value: 20 }] }, + ], + drawingArea: { top: 10, height: 120 }, + colors, + formatValue: value => `value-${value}`, + isDarkMode: false, + }) + + expect(result).toContain(' { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 50, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 55, value: 20 }] }, + ], + drawingArea: { top: 20, bottom: 120 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain('y="50"') + expect(result).toContain('y="80"') + }) + + it('keeps colliding labels close to their related points without overlapping', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 40, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 45, value: 20 }] }, + { color: '#0000FF', plots: [{ x: 100, y: 95, value: 30 }] }, + ], + drawingArea: { top: 0, height: 120 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain('y="40"') + expect(result).toContain('y="70"') + expect(result).toContain('y="100"') + }) + + it('shifts the label stack upward when it overflows the drawing area bottom', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 80, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 85, value: 20 }] }, + { color: '#0000FF', plots: [{ x: 100, y: 90, value: 30 }] }, + ], + drawingArea: { top: 0, height: 120 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain('y="45"') + expect(result).toContain('y="75"') + expect(result).toContain('y="105"') + }) + + it('uses dark-mode opacity in collision mode', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 50, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 50, value: 20 }] }, + ], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `${value}`, + isDarkMode: true, + }) + + expect(result).toContain('opacity="0.7"') + }) + + it('uses the fallback serie color when no color is provided', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { plots: [{ x: 100, y: 50, value: 10 }] }, + { plots: [{ x: 100, y: 50, value: 20 }] }, + ], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain('stroke="#999999"') + }) + + it('supports null plot values from SSR slot data', () => { + const result = createLastDatapointLabelsSvg({ + series: [{ plots: [{ x: 10, y: 20, value: null }] }], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `formatted:${value}`, + isDarkMode: false, + }) + + expect(result).toContain('formatted:0') + }) + + it('uses zero defaults when plot coordinates are nullish', () => { + const result = createLastDatapointLabelsSvg({ + series: [{ plots: [{ x: null, y: null, value: undefined }] }], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `value-${value}`, + isDarkMode: false, + }) + + expect(result).toContain('x="24"') + expect(result).toContain('y="0"') + expect(result).toContain('value-0') + }) + + it('uses zero drawing area height when neither height nor bottom is provided', () => { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 50, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 50, value: 20 }] }, + ], + drawingArea: { top: 20 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain('y="-45"') + expect(result).toContain('y="-15"') + }) + + it('renders a regular label when serie color is omitted', () => { + const result = createLastDatapointLabelsSvg({ + series: [{ plots: [{ x: 100, y: 50, value: 10 }] }], + drawingArea: { top: 0, height: 100 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + }) + + expect(result).toContain(' { + const result = createLastDatapointLabelsSvg({ + series: [ + { color: '#FF0000', plots: [{ x: 100, y: 50, value: 10 }] }, + { color: '#00FF00', plots: [{ x: 100, y: 50, value: 20 }] }, + ], + drawingArea: { top: 10, height: 110 }, + colors, + formatValue: value => `${value}`, + isDarkMode: false, + labelHeight: 20, + }) + + expect(result).toContain('y="50"') + expect(result).toContain('y="70"') + }) +}) diff --git a/test/unit/shared/utils/download-ranges.spec.ts b/test/unit/shared/utils/download-ranges.spec.ts new file mode 100644 index 0000000000..dbcb330f56 --- /dev/null +++ b/test/unit/shared/utils/download-ranges.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + differenceInUtcDaysInclusive, + mergeDailyPoints, + splitIsoRangeIntoChunksInclusive, +} from '#shared/utils/download-ranges' + +describe('differenceInUtcDaysInclusive', () => { + it('returns 1 for the same start and end date', () => { + expect(differenceInUtcDaysInclusive('2026-05-31', '2026-05-31')).toBe(1) + }) + + it('counts both start and end dates inclusively', () => { + expect(differenceInUtcDaysInclusive('2026-05-01', '2026-05-31')).toBe(31) + }) + + it('handles ranges across month and year boundaries', () => { + expect(differenceInUtcDaysInclusive('2025-12-31', '2026-01-02')).toBe(3) + }) +}) + +describe('splitIsoRangeIntoChunksInclusive', () => { + it('returns a single chunk when total days is below the maximum', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 20)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-10', + }, + ]) + }) + + it('returns a single chunk when total days equals the maximum', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 10)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-10', + }, + ]) + }) + + it('splits a range into inclusive chunks', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-05-01', '2026-05-10', 4)).toEqual([ + { + startIso: '2026-05-01', + endIso: '2026-05-04', + }, + { + startIso: '2026-05-05', + endIso: '2026-05-08', + }, + { + startIso: '2026-05-09', + endIso: '2026-05-10', + }, + ]) + }) + + it('splits ranges across month boundaries', () => { + expect(splitIsoRangeIntoChunksInclusive('2026-01-30', '2026-02-03', 2)).toEqual([ + { + startIso: '2026-01-30', + endIso: '2026-01-31', + }, + { + startIso: '2026-02-01', + endIso: '2026-02-02', + }, + { + startIso: '2026-02-03', + endIso: '2026-02-03', + }, + ]) + }) + + it('splits ranges across year boundaries', () => { + expect(splitIsoRangeIntoChunksInclusive('2025-12-30', '2026-01-02', 2)).toEqual([ + { + startIso: '2025-12-30', + endIso: '2025-12-31', + }, + { + startIso: '2026-01-01', + endIso: '2026-01-02', + }, + ]) + }) +}) + +describe('mergeDailyPoints', () => { + it('returns an empty array when there are no points', () => { + expect(mergeDailyPoints([])).toEqual([]) + }) + + it('sorts points by day', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-03', value: 30 }, + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 20 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 20 }, + { day: '2026-05-03', value: 30 }, + ]) + }) + + it('merges duplicate days by summing their values', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-02', value: 20 }, + { day: '2026-05-01', value: 10 }, + { day: '2026-05-02', value: 5 }, + { day: '2026-05-01', value: 15 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 25 }, + { day: '2026-05-02', value: 25 }, + ]) + }) + + it('preserves negative and zero values when merging', () => { + expect( + mergeDailyPoints([ + { day: '2026-05-01', value: 10 }, + { day: '2026-05-01', value: -5 }, + { day: '2026-05-02', value: 0 }, + ]), + ).toEqual([ + { day: '2026-05-01', value: 5 }, + { day: '2026-05-02', value: 0 }, + ]) + }) +}) diff --git a/test/unit/shared/utils/trends-chart.spec.ts b/test/unit/shared/utils/trends-chart.spec.ts new file mode 100644 index 0000000000..2f72a04acf --- /dev/null +++ b/test/unit/shared/utils/trends-chart.spec.ts @@ -0,0 +1,955 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildNormalisedTrendsDataset, + buildTrendsChartConfig, + buildTrendsChartData, + drawTrendsEstimationLine, + drawTrendsLastDatapointLabel, + drawTrendsSvgPrintLegend, + generateWatermarkLogo, + getTrendsDatetimeFormatterOptions, + isDailyDataset, + isMonthlyDataset, + isWeeklyDataset, + isYearlyDataset, +} from '#shared/utils/trends-chart' + +const { + lightenOklchMock, + getFrameworkColorMock, + isListedFrameworkMock, + applyEllipsisMock, + applyDataPipelineMock, +} = vi.hoisted(() => ({ + lightenOklchMock: vi.fn(), + getFrameworkColorMock: vi.fn(), + isListedFrameworkMock: vi.fn(), + applyEllipsisMock: vi.fn(), + applyDataPipelineMock: vi.fn(), +})) + +vi.mock('~/utils/colors', () => ({ + OKLCH_NEUTRAL_FALLBACK: 'oklch-fallback', + lightenOklch: lightenOklchMock, +})) + +vi.mock('~/utils/frameworks', () => ({ + getFrameworkColor: getFrameworkColorMock, + isListedFramework: isListedFrameworkMock, +})) + +vi.mock('~/utils/charts', () => ({ + applyEllipsis: applyEllipsisMock, +})) + +vi.mock('~/utils/chart-data-prediction', () => ({ + DEFAULT_PREDICTION_POINTS: 12, + applyDataPipeline: applyDataPipelineMock, +})) + +const colors = { + bg: '#ffffff', + bgElevated: '#f8f8f8', + fg: '#111111', + fgMuted: '#666666', + fgSubtle: '#999999', + border: '#dddddd', +} + +const translate = (key: string, params?: Record) => { + if (!params) { + return key + } + + return `${key}:${JSON.stringify(params)}` +} + +const compactNumberFormatter = new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +function baseChartDataOptions() { + return { + packageNames: ['vue'], + isMultiPackageMode: false, + selectedMetric: 'downloads' as const, + selectedMetricLabel: 'Downloads', + selectedGranularity: 'daily' as const, + displayedGranularity: 'daily' as const, + singleEvolution: [], + colors, + isDarkMode: false, + chartFilter: { + averageWindow: 1, + smoothingTau: 0, + }, + t: translate, + compactNumberFormatter, + } +} + +function baseConfigOptions() { + return { + packageNames: ['vue'], + isMultiPackageMode: false, + selectedMetric: 'downloads' as const, + selectedMetricLabel: 'Downloads', + selectedGranularity: 'daily' as const, + displayedGranularity: 'daily' as const, + singleEvolution: [], + colors, + isDarkMode: false, + chartFilter: { + averageWindow: 1, + smoothingTau: 0, + }, + t: translate, + compactNumberFormatter, + dates: [1, 2], + isMobile: false, + pending: false, + locale: 'en', + chartHeight: 400, + } +} + +beforeEach(() => { + vi.clearAllMocks() + + lightenOklchMock.mockReturnValue('lightened-accent') + getFrameworkColorMock.mockReturnValue('#42b883') + isListedFrameworkMock.mockImplementation((packageName: string) => packageName === 'vue') + applyEllipsisMock.mockImplementation((value: string) => value) + applyDataPipelineMock.mockImplementation((series: number[]) => series) +}) + +describe('dataset guards', () => { + it('detects weekly datasets', () => { + expect( + isWeeklyDataset([ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 1, + timestampEnd: 2, + value: 10, + }, + ]), + ).toBe(true) + + expect(isWeeklyDataset([])).toBe(false) + expect(isWeeklyDataset(null)).toBe(false) + expect(isWeeklyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects daily datasets', () => { + expect(isDailyDataset([{ day: '2026-01-01', timestamp: 1, value: 10 }])).toBe(true) + expect(isDailyDataset([])).toBe(false) + expect(isDailyDataset(null)).toBe(false) + expect(isDailyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects monthly datasets', () => { + expect(isMonthlyDataset([{ month: '2026-01', timestamp: 1, value: 10 }])).toBe(true) + expect(isMonthlyDataset([])).toBe(false) + expect(isMonthlyDataset(null)).toBe(false) + expect(isMonthlyDataset([{ value: 10 }])).toBe(false) + }) + + it('detects yearly datasets', () => { + expect(isYearlyDataset([{ year: '2026', timestamp: 1, value: 10 }])).toBe(true) + expect(isYearlyDataset([])).toBe(false) + expect(isYearlyDataset(null)).toBe(false) + expect(isYearlyDataset([{ value: 10 }])).toBe(false) + }) +}) + +describe('buildTrendsChartData', () => { + it('formats a single daily dataset', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [ + { day: '2026-01-01', timestamp: 1, value: 10, hasAnomaly: true }, + { day: '2026-01-02', timestamp: 2, value: 20 }, + ], + accent: '#abcdef', + }) + + expect(result).toEqual({ + dataset: [ + expect.objectContaining({ + name: 'vue', + type: 'line', + series: [10, 20], + color: '#abcdef', + temperatureColors: undefined, + useArea: true, + dashIndices: [0], + }), + ], + dates: [1, 2], + }) + }) + + it('uses an empty series name when packageNames is empty in single-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: [], + singleEvolution: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }) + + expect(result.dataset?.[0]?.name).toBe('') + expect(applyEllipsisMock).toHaveBeenCalledWith('', 32) + }) + + it('formats weekly, monthly, and yearly single datasets', () => { + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'weekly', + displayedGranularity: 'weekly', + singleEvolution: [ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 1, + timestampEnd: 2, + value: 10, + }, + ], + }).dates, + ).toEqual([2]) + + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'monthly', + displayedGranularity: 'monthly', + singleEvolution: [{ month: '2026-01', timestamp: 3, value: 10 }], + }).dates, + ).toEqual([3]) + + expect( + buildTrendsChartData({ + ...baseChartDataOptions(), + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + singleEvolution: [{ year: '2026', timestamp: 4, value: 10 }], + }).dates, + ).toEqual([4]) + }) + + it('returns null for an invalid single dataset', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [], + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('uses fallback accent and dark mode temperature colors', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + singleEvolution: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + colors: { ...colors, fgSubtle: undefined as never }, + isDarkMode: true, + }) + + expect(lightenOklchMock).toHaveBeenCalledWith('oklch-fallback', 0.618) + expect(result.dataset?.[0]).toMatchObject({ + color: 'oklch-fallback', + temperatureColors: ['lightened-accent', 'oklch-fallback'], + }) + }) + + it('extracts daily points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ day: '2026-01-02', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + color: '#42b883', + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts monthly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'monthly', + displayedGranularity: 'monthly', + evolutionsByPackage: { + vue: [{ month: '2026-01', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ month: '2026-02', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts yearly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + evolutionsByPackage: { + vue: [{ year: '2025', timestamp: 100, value: 10, hasAnomaly: true }], + react: [{ year: '2026', timestamp: 200, value: 20 }], + }, + }) + + expect(result.dates).toEqual([100, 200]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('extracts weekly points in multi-package mode', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'weekly', + displayedGranularity: 'weekly', + evolutionsByPackage: { + vue: [ + { + weekKey: '2026-W01', + weekStart: '2026-01-01', + weekEnd: '2026-01-07', + timestampStart: 100, + timestampEnd: 200, + value: 10, + hasAnomaly: true, + }, + ], + react: [ + { + weekKey: '2026-W02', + weekStart: '2026-01-08', + weekEnd: '2026-01-14', + timestampStart: 201, + timestampEnd: 300, + value: 20, + }, + ], + }, + }) + + expect(result.dates).toEqual([200, 300]) + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10, 0], + dashIndices: [0], + }), + expect.objectContaining({ + name: 'react', + series: [0, 20], + dashIndices: [], + }), + ]) + }) + + it('falls back to packageNames when effectivePackageNamesForMetric is not provided', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + }) + + expect(result.dataset?.[0]?.name).toBe('vue') + }) + + it('falls back to an empty point list when a mapped package was not collected', () => { + const effectivePackageNamesForMetric = ['vue'] as unknown as string[] + + effectivePackageNamesForMetric.map = ((callback: (packageName: string) => unknown) => + ['vue', 'react'].map(callback)) as typeof effectivePackageNamesForMetric.map + + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue', 'react'], + effectivePackageNamesForMetric, + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + }) + + expect(result.dataset).toEqual([ + expect.objectContaining({ + name: 'vue', + series: [10], + }), + expect.objectContaining({ + name: 'react', + series: [0], + }), + ]) + }) + + it('returns null for multi-package mode when no dates are available', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + isMultiPackageMode: true, + evolutionsByPackage: {}, + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('returns null for multi-package mode when extracted points are invalid', () => { + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + selectedGranularity: 'daily', + displayedGranularity: 'daily', + evolutionsByPackage: { + vue: [{ month: '2026-01', timestamp: 1, value: 10 }] as never, + }, + }) + + expect(result).toEqual({ + dataset: null, + dates: [], + }) + }) + + it('applies anomaly correction for downloads when enabled', () => { + const applyAnomalyCorrection = vi.fn(() => [{ day: '2026-01-01', timestamp: 1, value: 99 }]) + + const result = buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + useAnomalyCorrection: true, + applyAnomalyCorrection, + }) + + expect(applyAnomalyCorrection).toHaveBeenCalledWith({ + data: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + packageName: 'vue', + granularity: 'daily', + }) + expect(result.dataset?.[0]?.series).toEqual([99]) + }) + + it('does not apply anomaly correction for non-download metrics', () => { + const applyAnomalyCorrection = vi.fn() + + buildTrendsChartData({ + ...baseChartDataOptions(), + packageNames: ['vue'], + isMultiPackageMode: true, + selectedMetric: 'likes', + selectedMetricLabel: 'Likes', + evolutionsByPackage: { + vue: [{ day: '2026-01-01', timestamp: 1, value: 10 }], + }, + useAnomalyCorrection: true, + applyAnomalyCorrection, + }) + + expect(applyAnomalyCorrection).not.toHaveBeenCalled() + }) +}) + +describe('buildNormalisedTrendsDataset', () => { + it('returns an empty array for null dataset', () => { + expect( + buildNormalisedTrendsDataset({ + dataset: null, + dates: [], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 1, smoothingTau: 0 }, + }), + ).toEqual([]) + }) + + it('normalizes number, object, and invalid values before applying the pipeline', () => { + applyDataPipelineMock.mockReturnValue([1, 2, 0]) + + const result = buildNormalisedTrendsDataset({ + dataset: [ + { + name: 'vue', + type: 'line', + series: [1, { x: 1, y: 2 }, 'bad'] as never, + }, + ], + dates: [10], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 2, smoothingTau: 3 }, + endDateMs: 500, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1, 2, 0], + { + averageWindow: 2, + smoothingTau: 3, + predictionPoints: 12, + }, + { + granularity: 'daily', + lastDateMs: 10, + referenceMs: 500, + isAbsoluteMetric: false, + }, + ) + + expect(result).toEqual([ + expect.objectContaining({ + series: [1, 2, 0], + dashIndices: [], + }), + ]) + }) + + it('uses Date.now when endDateMs is missing', () => { + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(999) + + buildNormalisedTrendsDataset({ + dataset: [{ name: 'vue', type: 'line', series: [1] }], + dates: [], + granularity: 'daily', + selectedMetric: 'downloads', + chartFilter: { averageWindow: 1, smoothingTau: 0, predictionPoints: 4 }, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + predictionPoints: 4, + }), + expect.objectContaining({ + lastDateMs: 0, + referenceMs: 999, + }), + ) + + dateNowSpy.mockRestore() + }) + + it('disables prediction points for weekly granularity', () => { + buildNormalisedTrendsDataset({ + dataset: [{ name: 'vue', type: 'line', series: [1], dashIndices: [0] }], + dates: [10], + granularity: 'weekly', + selectedMetric: 'contributors', + chartFilter: { averageWindow: 0, smoothingTau: 0, predictionPoints: 5 }, + endDateMs: 100, + }) + + expect(applyDataPipelineMock).toHaveBeenCalledWith( + [1], + { + averageWindow: 0, + smoothingTau: 0, + predictionPoints: 0, + }, + { + granularity: 'weekly', + lastDateMs: 10, + referenceMs: 100, + isAbsoluteMetric: true, + }, + ) + }) +}) + +describe('getTrendsDatetimeFormatterOptions', () => { + it('returns formatter options for each granularity', () => { + expect(getTrendsDatetimeFormatterOptions('daily')).toEqual({ + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }) + + expect(getTrendsDatetimeFormatterOptions('weekly')).toEqual({ + year: 'yyyy-MM-dd', + month: 'yyyy-MM-dd', + day: 'yyyy-MM-dd', + }) + + expect(getTrendsDatetimeFormatterOptions('monthly')).toEqual({ + year: 'MMM yyyy', + month: 'MMM yyyy', + day: 'MMM yyyy', + }) + + expect(getTrendsDatetimeFormatterOptions('yearly')).toEqual({ + year: 'yyyy', + month: 'yyyy', + day: 'yyyy', + }) + }) +}) + +describe('buildTrendsChartConfig', () => { + it('builds a light desktop config with default tooltip behavior', () => { + const config = buildTrendsChartConfig(baseConfigOptions()) + + expect(config.theme).toBe('') + expect(config.chart?.height).toBe(400) + expect(config.chart?.padding?.bottom).toBe(64) + expect(config.chart?.grid?.labels?.fontSize).toBe(16) + expect(config.chart?.tooltip?.teleportTo).toBeUndefined() + expect(config.chart?.tooltip?.position).toBe('center') + expect(config.chart?.tooltip?.offsetY).toBe(-24) + expect(config.chart?.timeTag?.backgroundColor).toBe('#f8f8f8') + }) + + it('falls back to bg when bgElevated is not provided', () => { + const config = buildTrendsChartConfig({ + ...baseConfigOptions(), + colors: { + ...colors, + bgElevated: undefined as never, + }, + }) + + expect(config.chart?.timeTag?.backgroundColor).toBe(colors.bg) + }) + + it('builds a dark mobile yearly multi-package config', () => { + const config = buildTrendsChartConfig({ + ...baseConfigOptions(), + packageNames: ['vue', 'react'], + isMultiPackageMode: true, + selectedGranularity: 'yearly', + displayedGranularity: 'yearly', + isDarkMode: true, + dates: [1, 2], + isMobile: true, + pending: true, + locale: 'fr-FR', + chartHeight: 500, + inModal: true, + tooltipPosition: 'left', + }) + + expect(config.theme).toBe('dark') + expect(config.chart?.padding?.bottom).toBe(84) + expect(config.chart?.grid?.labels?.fontSize).toBe(24) + expect(config.chart?.grid?.labels?.color).toBe(colors.border) + expect(config.chart?.grid?.labels?.axis?.fontSize).toBe(32) + expect(config.chart?.tooltip?.teleportTo).toBe('#chart-modal') + expect(config.chart?.tooltip?.position).toBe('left') + expect(config.chart?.tooltip?.offsetY).toBeUndefined() + }) + + it('formats finite and non-finite y-axis values', () => { + const config = buildTrendsChartConfig(baseConfigOptions()) + + const formatter = config.chart?.grid?.labels?.yAxis?.formatter as (payload: { + value: number + }) => string + + expect(formatter({ value: 1200 })).toBe('1.2K') + expect(formatter({ value: Number.NaN })).toBe('0') + }) +}) + +describe('drawTrendsEstimationLine', () => { + it('returns an empty string when rendering is disabled or no data exists', () => { + expect( + drawTrendsEstimationLine({ + svg: {}, + colors, + shouldRender: false, + }), + ).toBe('') + + expect( + drawTrendsEstimationLine({ + svg: {}, + colors, + shouldRender: true, + }), + ).toBe('') + }) + + it('draws estimation lines from svg data', () => { + const result = drawTrendsEstimationLine({ + svg: { + data: [ + { + color: '#ff0000', + plots: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toContain('x1="1"') + expect(result).toContain('x2="3"') + expect(result).toContain('stroke="#ff0000"') + expect(result).toContain(' { + const result = drawTrendsEstimationLine({ + svg: { + series: [ + { + plots: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + }, + { + plots: [{ x: 1, y: 2 }], + }, + { + plots: null, + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toContain(`stroke="${colors.fg}"`) + }) + + it('skips estimation lines when previous or last plot is missing', () => { + const result = drawTrendsEstimationLine({ + svg: { + data: [ + { + color: '#ff0000', + plots: [{ x: 1, y: 2 }, undefined], + }, + { + color: '#00ff00', + plots: [null, { x: 3, y: 4 }], + }, + ], + }, + colors, + shouldRender: true, + }) + + expect(result).toBe('') + }) +}) + +describe('drawTrendsLastDatapointLabel', () => { + it('returns an empty string when no data exists', () => { + expect( + drawTrendsLastDatapointLabel({ + svg: {}, + colors, + compactNumberFormatter, + }), + ).toBe('') + }) + + it('draws last datapoint labels from svg data', () => { + const result = drawTrendsLastDatapointLabel({ + svg: { + data: [ + { + plots: [{ x: 1, y: 2, value: 1200 }], + }, + ], + }, + colors, + compactNumberFormatter, + }) + + expect(result).toContain('1.2K') + expect(result).toContain('x="13"') + }) + + it('draws last datapoint labels and falls back to zero for non-finite values', () => { + const result = drawTrendsLastDatapointLabel({ + svg: { + series: [ + { + plots: [{ x: 1, y: 2, value: 1200 }], + }, + { + plots: [{ x: 3, y: 4, value: Number.NaN }], + }, + { + plots: [], + }, + ], + }, + colors, + compactNumberFormatter, + }) + + expect(result).toContain('1.2K') + expect(result).toContain('0') + expect(result).toContain('x="13"') + }) +}) + +describe('drawTrendsSvgPrintLegend', () => { + it('returns an empty string when no data exists', () => { + expect( + drawTrendsSvgPrintLegend({ + svg: {}, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }), + ).toBe('') + + expect( + drawTrendsSvgPrintLegend({ + svg: { + data: [], + series: [ + { + name: 'ignored', + color: '#000000', + }, + ], + }, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }), + ).toBe('') + }) + + it('draws the print legend without estimation legend from data', () => { + const result = drawTrendsSvgPrintLegend({ + svg: { + drawingArea: { + left: 10, + top: 20, + }, + data: [ + { + name: 'vue', + color: '#42b883', + }, + ], + }, + colors, + showEstimationLegend: false, + estimationLabel: 'Estimated', + }) + + expect(result).toContain('vue') + expect(result).toContain('fill="#42b883"') + expect(result).not.toContain('Estimated') + }) + + it('draws the print legend with estimation legend using series fallback', () => { + const result = drawTrendsSvgPrintLegend({ + svg: { + drawingArea: { + left: 10, + top: 20, + }, + series: [ + { + name: 'vue', + color: '#42b883', + }, + ], + }, + colors, + showEstimationLegend: true, + estimationLabel: 'Estimated', + }) + + expect(result).toContain('vue') + expect(result).toContain('Estimated') + expect(result).toContain('stroke-dasharray="4"') + }) +}) + +describe('generateWatermarkLogo', () => { + it('generates an SVG watermark logo', () => { + const result = generateWatermarkLogo({ + x: 1, + y: 2, + width: 3, + height: 4, + fill: '#123456', + }) + + expect(result).toContain('x="1"') + expect(result).toContain('y="2"') + expect(result).toContain('width="3"') + expect(result).toContain('height="4"') + expect(result).toContain('fill="#123456"') + }) +}) diff --git a/uno.config.ts b/uno.config.ts index ddbbafb7a0..426b9dd40b 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -10,17 +10,7 @@ import { import { presetRtl } from './uno-preset-rtl' import { presetA11y } from './uno-preset-a11y' import { theme } from './uno.theme' - -const customIcons = { - 'agent-skills': - '', - 'tangled': - '', - 'vlt': - '', - 'a11y': - '', -} +import customIcons from './assets/media/custom-icons.json' export default defineConfig({ // og-image uses hardcoded classes we don't want bundled into main app @@ -33,6 +23,11 @@ export default defineConfig({ // Exclude OG image templates from the pipeline '**/OgImage/*.takumi.vue', ], + include: [ + /\.(vue|mdx|html)($|\?)/, + // git provider icons composable + '**/composables/useProviderIcon.ts', + ], }, }, presets: [ @@ -45,14 +40,16 @@ export default defineConfig({ warn: true, scale: 1.2, collections: { - custom: customIcons, + custom: Object.fromEntries( + Object.entries(customIcons).map(([key, { body }]) => [key, body]), + ), }, }), presetTypography(), // keep this preset last ...(process.env.CI ? [] : [presetRtl(), presetA11y()]), ].filter(Boolean), - transformers: [transformerDirectives(), transformerVariantGroup()], + transformers: [transformerDirectives({ enforce: 'pre' }), transformerVariantGroup()], theme, shortcuts: [ // Layout