Thank you for your interest in contributing! ❤️ This document provides guidelines and instructions for contributing.
Important
Please be respectful and constructive in all interactions. We aim to maintain a welcoming environment for all contributors. 👉 Read more
The goal of npmx.dev is to build a fast, modern and open-source browser for the npm registry, prioritizing speed, simplicity and a community-driven developer experience.
- Speed
- Simplicity
- Community-first
npmx is built for open-source developers, by open-source developers.
Our goal is to create tools and capabilities that solve real problems for package maintainers and power users, while also providing a great developer experience for everyone who works in the JavaScript ecosystem.
This focus helps guide our project decisions as a community and what we choose to build.
- Getting started
- Development workflow
- Code style
- RTL Support
- Localization (i18n)
- Testing
- Submitting changes
- Pre-commit hooks
- Using AI
- Questions
- License
-
fork and clone the repository
-
install dependencies:
pnpm install
-
start the development server:
pnpm dev
-
(optional) if you want to test the admin UI/flow, you can run the local connector:
pnpm npmx-connector
# Development
pnpm dev # Start development server
pnpm build # Production build
pnpm preview # Preview production build
# Code Quality
pnpm lint # Run linter (oxlint + oxfmt)
pnpm lint:fix # Auto-fix lint issues
pnpm test:types # TypeScript type checking
# Testing
pnpm test # Run all Vitest tests
pnpm test:unit # Unit tests only
pnpm test:nuxt # Nuxt component tests
pnpm test:browser # Playwright E2E tests
pnpm test:a11y # Lighthouse accessibility auditsapp/ # Nuxt 4 app directory
├── components/ # Vue components (PascalCase.vue)
├── composables/ # Vue composables (useFeature.ts)
├── pages/ # File-based routing
├── plugins/ # Nuxt plugins
├── app.vue # Root component
└── error.vue # Error page
server/ # Nitro server
├── api/ # API routes
└── utils/ # Server utilities
shared/ # Shared between app and server
└── types/ # TypeScript type definitions
cli/ # Local connector CLI (separate workspace)
test/ # Vitest tests
├── unit/ # Unit tests (*.spec.ts)
└── nuxt/ # Nuxt component tests
tests/ # Playwright E2E tests
Tip
For more about the meaning of these directories, check out the docs on the Nuxt directory structure.
The cli/ workspace contains a local connector that enables authenticated npm operations from the web UI. It runs on your machine and uses your existing npm credentials.
# run the connector from the root of the repository
pnpm npmx-connectorThe connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev.
When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
To help with this, the project uses oxfmt to handle formatting via a pre-commit hook. The hook will automatically reformat files when needed. If something can’t be fixed automatically, it will let you know what needs to be updated before you can commit.
If you want to get ahead of any formatting issues, you can also run pnpm lint:fix before committing to fix formatting across the whole project.
When displaying the project name anywhere in the UI, use npmx in all lowercase letters.
- We care about good types – never cast things to
any💪 - Validate rather than just assert
Use Valibot schemas from #shared/schemas/ to validate API inputs. This ensures type safety and provides consistent error messages:
import * as v from 'valibot'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
// In your handler:
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})Use the handleApiError utility for consistent error handling in API routes. It re-throws H3 errors (like 404s) and wraps other errors with a fallback message:
import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
try {
// API logic...
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: ERROR_NPM_FETCH_FAILED,
})
}Use parsePackageParams to extract package name and version from URL segments:
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)This handles patterns like /pkg, /pkg/v/1.0.0, /@scope/pkg, and /@scope/pkg/v/1.0.0.
Define error messages and other string constants in #shared/utils/constants.ts to ensure consistency across the codebase:
export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'- Type imports first (
import type { ... }) - External packages
- Internal aliases (
#shared/types,#server/, etc.) - No blank lines between groups
import type { Packument, NpmSearchResponse } from '#shared/types'
import type { Tokens } from 'marked'
import { marked } from 'marked'
import { hasProtocol } from 'ufo'| Type | Convention | Example |
|---|---|---|
| Vue components | PascalCase | DateTime.vue |
| Pages | kebab-case | search.vue, [...name].vue |
| Composables | camelCase + use prefix |
useNpmRegistry.ts |
| Server routes | kebab-case + method | search.get.ts |
| Functions | camelCase | fetchPackage, formatDate |
| Constants | SCREAMING_SNAKE_CASE | NPM_REGISTRY, ALLOWED_TAGS |
| Types/Interfaces | PascalCase | NpmSearchResponse |
Tip
Exports in app/composables/, app/utils/, and server/utils/ are auto-imported by Nuxt. To prevent knip from flagging them as unused, add a @public JSDoc annotation:
/**
* @public
*/
export function myAutoImportedFunction() {
// ...
}- Use Composition API with
<script setup lang="ts"> - Define props with TypeScript:
defineProps<{ text: string }>() - Keep functions under 50 lines
- Accessibility is a first-class consideration – always consider ARIA attributes and keyboard navigation
<script setup lang="ts">
import type { PackumentVersion } from '#shared/types'
const props = defineProps<{
version: PackumentVersion
}>()
</script>Ideally, extract utilities into separate files so they can be unit tested. 🙏
Always use object syntax with named routes for internal navigation. This makes links resilient to URL structure changes and provides type safety via unplugin-vue-router.
<!-- Good: named route -->
<NuxtLink :to="{ name: 'settings' }">Settings</NuxtLink>
<!-- Bad: string path -->
<NuxtLink to="/settings">Settings</NuxtLink>The same applies to programmatic navigation:
// Good
navigateTo({ name: 'compare' })
router.push({ name: 'search' })
// Bad
navigateTo('/compare')
router.push('/search')For routes with parameters, pass them explicitly:
<NuxtLink :to="{ name: '~username', params: { username } }">Profile</NuxtLink>
<NuxtLink :to="{ name: 'org', params: { org: orgName } }">Organization</NuxtLink>Query parameters work as expected:
<NuxtLink :to="{ name: 'compare', query: { packages: pkg.name } }">Compare</NuxtLink>For package links, use the auto-imported packageRoute() utility from app/utils/router.ts. It handles scoped/unscoped packages and optional versions:
<!-- Links to /package/vue -->
<NuxtLink :to="packageRoute('vue')">vue</NuxtLink>
<!-- Links to /package/@nuxt/kit -->
<NuxtLink :to="packageRoute('@nuxt/kit')">@nuxt/kit</NuxtLink>
<!-- Links to /package/vue/v/3.5.0 -->
<NuxtLink :to="packageRoute('vue', '3.5.0')">vue@3.5.0</NuxtLink>Important
Never construct package URLs as strings. The route structure uses separate org and name params, and packageRoute() handles the splitting correctly.
| 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 |
We support right-to-left languages, we need to make sure that the UI is working correctly in both directions.
Simple approach used by most websites of relying on direction set in HTML element does not work because direction for various items, such as timeline, does not always match direction set in HTML.
We've added some UnoCSS utilities styles to help you with that:
- Do not use
left/rightpadding and margin: for examplepl-1. Usepadding-inline-start/endinstead. Sopl-1should beps-1,pr-1should bepe-1. The same rules apply to margin. - Do not use
rtl-classes, such asrtl-left-0. - For icons that should be rotated for RTL, add
class="rtl-flip". This can only be used for icons outside of elements withdir="auto". - For absolute positioned elements, don't use
left/right: for exampleleft-0. Useinset-inline-start/endinstead.UnoCSSshortcuts areinset-isforinset-inline-startandinset-ieforinset-inline-end. Example:left-0should be replaced withinset-is-0. - If you need to change the border radius for an entire left or right side, use
border-inline-start/end.UnoCSSshortcuts arerounded-isfor left side,rounded-iefor right side. Example:rounded-l-5should be replaced withrounded-is-5. - If you need to change the border radius for one corner, use
border-start-end-radiusand similar rules.UnoCSSshortcuts arerounded+ top/bottom as either-bs(top) or-be(bottom) + left/right as either-is(left) or-ie(right). Example:rounded-tl-0should be replaced withrounded-bs-is-0.
npmx.dev uses @nuxtjs/i18n for internationalization. We aim to make the UI accessible to users in their preferred language.
- All user-facing strings should use translation keys via
$t()in templates and script - Translation files live in
i18n/locales/(e.g.,en-US.json) - We use the
no_prefixstrategy (no/en-US/or/fr-FR/in URLs) - Locale preference is stored in cookies and respected on subsequent visits
We are using localization using country variants (ISO-6391) via multiple translation files to avoid repeating every key per country.
The config/i18n.ts configuration file will be used to register the new locale:
countryLocaleVariantsobject will be used to register the country variantslocalesobject will be used to link the supported locales (country and single one)buildLocalesfunction will build the target locales
To add a new locale:
-
Create a new JSON file in
i18n/locales/with the locale code as the filename (e.g.,uk-UA.json,de-DE.json) -
Copy
en.jsonand translate the strings -
Add the locale to the
localesarray in config/i18n.ts:{ code: 'uk-UA', // Must match the filename (without .json) file: 'uk-UA.json', name: 'Українська', // Native name of the language },
-
Copy your translation file to
lunaria/files/for translation tracking:cp i18n/locales/uk-UA.json lunaria/files/uk-UA.json
⚠Important: This file must be committed. Lunaria uses git history to track translation progress, so the build will fail if this file is missing.
-
If the language is
right-to-left, adddir: 'rtl'(seear-EGin config for example) -
If the language requires special pluralization rules, add a
pluralRulecallback (seear-EGorru-RUin config for examples)
Check Pluralization rule callback and Plural Rules for more info.
We track the current progress of translations with Lunaria on this site: https://i18n.npmx.dev/ If you see any outdated translations in your language, feel free to update the keys to match the English version.
In order to make sure you have everything up-to-date, you can run:
pnpm i18n:check <country-code>For example to check if all Japanese translation keys are up-to-date, run:
pnpm i18n:check ja-JPTo automatically add missing keys with English placeholders, use --fix:
pnpm i18n:check:fix fr-FRThis will add missing keys with "EN TEXT TO REPLACE: {english text}" as placeholder values, making it easier to see what needs translation.
Most languages only need a single locale file. Country variants are only needed when you want to support regional differences (e.g., es-ES for Spain vs es-419 for Latin America).
If you need country variants:
- Create a base language file (e.g.,
es.json) with all translations - Create country variant files (e.g.,
es-ES.json,es-419.json) with only the differing translations - Register the base language in
localesand add variants tocountryLocaleVariants
See how es, es-ES, and es-419 are configured in config/i18n.ts for a complete example.
-
Add your translation key to
i18n/locales/en.jsonfirst (American English is the source of truth) -
Use the key in your component:
<template> <p>{{ $t('my.translation.key') }}</p> </template>
Or in script:
<script setup lang="ts"> const message = computed(() => $t('my.translation.key')) </script>
-
For dynamic values, use interpolation:
{ "greeting": "Hello, {name}!" }<p>{{ $t('greeting', { name: userName }) }}</p>
-
Don't concatenate string messages in the Vue templates, some languages can have different word order. Use placeholders instead.
Bad:
<p>{{ $t('hello') }} {{ userName }}</p>
Good:
<p>{{ $t('greeting', { name: userName }) }}</p>
Complex content:
If you need to include HTML or components inside the translation, use
i18n-tcomponent. This is especially useful when the order of elements might change between languages.{ "agreement": "I accept the {terms} and {privacy}.", "terms_link": "Terms of Service", "privacy_policy": "Privacy Policy" }<i18n-t keypath="agreement" tag="p"> <template #terms> <NuxtLink to="/terms">{{ $t('terms_link') }}</NuxtLink> </template> <template #privacy> <strong>{{ $t('privacy_policy') }}</strong> </template> </i18n-t>
- Use dot notation for hierarchy:
section.subsection.key - Keep keys descriptive but concise
- Group related keys together
- Use
common.*for shared strings (loading, retry, close, etc.) - Use component-specific prefixes:
package.card.*,settings.*,nav.* - Do not use dashes (
-) in translation keys; always use underscore (_): e.g.,privacy_policyinstead ofprivacy-policy
We recommend the i18n-ally VSCode extension for a better development experience:
- Inline translation previews in your code
- Auto-completion for translation keys
- Missing translation detection
- Easy navigation to translation files
The extension is included in our workspace recommendations, so VSCode should prompt you to install it.
Use vue-i18n's built-in formatters for locale-aware formatting:
<template>
<p>{{ $n(12345) }}</p>
<!-- "12,345" in en-US, "12 345" in fr-FR -->
<p>{{ $d(new Date()) }}</p>
<!-- locale-aware date -->
</template>Write unit tests for core functionality using Vitest:
import { describe, it, expect } from 'vitest'
describe('featureName', () => {
it('should handle expected case', () => {
expect(result).toBe(expected)
})
})Tip
If you need access to the Nuxt context in your unit or component test, place your test in the test/nuxt/ directory and run with pnpm test:nuxt
All Vue components should have accessibility tests in test/nuxt/a11y.spec.ts. These tests use axe-core to catch common accessibility violations and run in a real browser environment via Playwright.
import { MyComponent } from '#components'
describe('MyComponent', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(MyComponent, {
props: {
/* required props */
},
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})The runAxe helper handles DOM isolation and disables page-level rules that don't apply to isolated component testing.
A coverage test in test/unit/a11y-component-coverage.spec.ts ensures all components are either tested or explicitly skipped with justification. When you add a new component, this test will fail until you add accessibility tests for it.
Important
Just because axe-core doesn't find any obvious issues, it does not mean a component is accessible. Please do additional checks and use best practices.
In addition to component-level axe audits, the project runs full-page accessibility audits using Lighthouse CI. These test the rendered pages in both light and dark mode against Lighthouse's accessibility category, requiring a perfect score.
- The project is built in test mode (
pnpm build:test), which activates server-side fixture mocking - Lighthouse CI starts a preview server and audits three URLs:
/,/search?q=nuxt, and/package/nuxt - A Puppeteer setup script (
lighthouse-setup.cjs) runs before each audit to set the color mode and intercept client-side API requests using the same fixtures as the E2E tests
# Build + run both light and dark audits
pnpm test:a11y
# Or against an existing test build
pnpm test:a11y:prebuilt
# Or run a single color mode manually
pnpm build:test
LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.shThis requires Chrome or Chromium to be installed. The script will auto-detect common installation paths. Results are printed to the terminal and saved in .lighthouseci/.
| File | Purpose |
|---|---|
.lighthouserc.cjs |
Lighthouse CI config (URLs, assertions, Chrome path) |
lighthouse-setup.cjs |
Puppeteer script for color mode + client-side API mocking |
scripts/lighthouse-a11y.sh |
Shell wrapper that runs the audit for a given color mode |
Write end-to-end tests using Playwright:
pnpm test:browser # Run tests
pnpm test:browser:ui # Run with Playwright UIMake sure to read about Playwright best practices and don't rely on classes/IDs but try to follow user-replicable behaviour (like selecting an element based on text content instead).
E2E tests use a fixture system to mock external API requests, ensuring tests are deterministic and don't hit real APIs. This is handled at two levels:
Server-side mocking (modules/fixtures.ts + modules/runtime/server/cache.ts):
- Intercepts all
$fetchcalls during SSR - Serves pre-recorded fixture data from
test/fixtures/ - Enabled via
NUXT_TEST_FIXTURES=trueor Nuxt test mode
Client-side mocking (test/fixtures/mock-routes.cjs):
- Shared URL matching and response generation logic used by both Playwright E2E tests and Lighthouse CI
- Playwright tests (
test/e2e/test-utils.ts) use this viapage.route()interception - Lighthouse tests (
lighthouse-setup.cjs) use this via Puppeteer request interception - All E2E test files import from
./test-utilsinstead of@nuxt/test-utils/playwright - Throws a clear error if an unmocked external request is detected
Fixtures are stored in test/fixtures/ with this structure:
test/fixtures/
├── npm-registry/
│ ├── packuments/ # Package metadata (vue.json, @nuxt/kit.json)
│ ├── search/ # Search results (vue.json, nuxt.json)
│ └── orgs/ # Org package lists (nuxt.json)
├── npm-api/
│ └── downloads/ # Download stats
└── users/ # User package lists
-
Generate fixtures using the script:
pnpm generate:fixtures vue lodash @nuxt/kit
-
Or manually create a JSON file in the appropriate directory
| Variable | Purpose |
|---|---|
NUXT_TEST_FIXTURES=true |
Enable server-side fixture mocking |
NUXT_TEST_FIXTURES_VERBOSE=true |
Enable detailed fixture logging |
If a test fails with an error like:
UNMOCKED EXTERNAL API REQUEST DETECTED
API: npm registry
URL: https://registry.npmjs.org/some-package
You need to either:
- Add a fixture file for that package/endpoint
- Update the mock handlers in
test/fixtures/mock-routes.cjs(client) ormodules/runtime/server/cache.ts(server)
- ensure your code follows the style guidelines
- run linting:
pnpm lint:fix - run type checking:
pnpm test:types - run tests:
pnpm test - write or update tests for your changes
- create a feature branch from
main - make your changes with clear, descriptive commits
- push your branch and open a pull request
- ensure CI checks pass (lint, type check, tests)
- request review from maintainers
Write clear, concise PR titles that explain the "why" behind changes.
We use Conventional Commits. Since we squash on merge, the PR title becomes the commit message in main, so it's important to get it right.
Format: type(scope): description
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Scopes (optional): docs, i18n, deps
Examples:
fix: resolve search pagination issuefeat: add package version comparisonfix(i18n): update French translationschore(deps): update vite to v6
Note
The subject must start with a lowercase letter. Individual commit messages within your PR don't need to follow this format since they'll be squashed.
If your pull request directly addresses an open issue, use the following inside your PR description.
Resolves | Fixes | Closes: #xxx
Replace #xxx with either a URL to the issue, or the number of the issue. For example:
Fixes #123
or
Closes https://github.com/npmx-dev/npmx.dev/issues/123
This provides the following benefits:
- it links the pull request to the issue (the merge icon will appear in the issue), so everybody can see there is an open PR
- when the pull request is merged, the linked issue is automatically closed
The project uses lint-staged with simple-git-hooks to automatically lint files on commit.
You're welcome to use AI tools to help you contribute. But there are two important ground rules:
When you write a comment, issue, or PR description, use your own words. Grammar and spelling don't matter – real connection does. AI-generated summaries tend to be long-winded, dense, and often inaccurate. Simplicity is an art. The goal is not to sound impressive, but to communicate clearly.
Feel free to use AI to write code, tests, or point you in the right direction. But always understand what it's written before contributing it. Take personal responsibility for your contributions. Don't say "ChatGPT says..." – tell us what you think.
For more context, see Using AI in open source.
If you have questions or need help, feel free to open an issue for discussion or join our Discord server.
By contributing to npmx.dev, you agree that your contributions will be licensed under the MIT License.