diff --git a/app/components/Code/DirectoryListing.vue b/app/components/Code/DirectoryListing.vue
index 31a6d1fe2..21ee58438 100644
--- a/app/components/Code/DirectoryListing.vue
+++ b/app/components/Code/DirectoryListing.vue
@@ -103,6 +103,7 @@ const bytesFormatter = useBytesFormatter()
>
└─
@@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
@@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {
diff --git a/app/components/Header/PackagesDropdown.vue b/app/components/Header/PackagesDropdown.vue
index 3f70c648f..364fa41ae 100644
--- a/app/components/Header/PackagesDropdown.vue
+++ b/app/components/Header/PackagesDropdown.vue
@@ -58,6 +58,7 @@ function handleKeydown(event: KeyboardEvent) {
@keydown="handleKeydown"
>
@@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
@@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {
diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue
index 4eeecda2b..bcb5b025b 100644
--- a/app/components/Link/Base.vue
+++ b/app/components/Link/Base.vue
@@ -35,6 +35,8 @@ const props = withDefaults(
/** should only be used for links where the context makes it very clear they are clickable. Don't just use this, because you don't like underlines. */
noUnderline?: boolean
+
+ trailingSlash?: 'append' | 'remove' | undefined | null
}>(),
{ variant: 'link', size: 'medium' },
)
@@ -74,6 +76,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && !isLink.value)
diff --git a/app/components/Org/TeamsPanel.vue b/app/components/Org/TeamsPanel.vue
index b0ddf5246..18fdab85b 100644
--- a/app/components/Org/TeamsPanel.vue
+++ b/app/components/Org/TeamsPanel.vue
@@ -418,6 +418,7 @@ watch(lastExecutionTime, () => {
class="flex items-center justify-start py-1 ps-2 pe-1 rounded hover:bg-bg-subtle transition-colors duration-200"
>
diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue
index 12d25268b..da527cc48 100644
--- a/app/components/Package/Card.vue
+++ b/app/components/Package/Card.vue
@@ -46,6 +46,7 @@ const numberFormatter = useNumberFormatter()
class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
>
{
{
{
{
:key="maintainer.username || maintainer.email"
>
1" :path="pkg.path" />
{{ vuln.summary }}
:text="$t('package.create.view', { packageName: createPackageInfo.packageName })"
>
diff --git a/app/components/VersionSelector.vue b/app/components/VersionSelector.vue
index 5a0867ee8..81509a8fe 100644
--- a/app/components/VersionSelector.vue
+++ b/app/components/VersionSelector.vue
@@ -554,6 +554,7 @@ watch(
,
requestedVersion: MaybeRefOrGetter,
) {
- return useFetch(
- () => {
+ return useAsyncData(
+ () => `resolved-version:${toValue(packageName)}:${toValue(requestedVersion) ?? 'latest'}`,
+ async () => {
const version = toValue(requestedVersion)
- return version
- ? `https://npm.antfu.dev/${toValue(packageName)}@${version}`
- : `https://npm.antfu.dev/${toValue(packageName)}`
- },
- {
- transform: (data: ResolvedPackageVersion) => data.version,
+ const name = toValue(packageName)
+ const url = version
+ ? `https://npm.antfu.dev/${name}@${version}`
+ : `https://npm.antfu.dev/${name}`
+ const data = await $fetch(url)
+ return data.version
},
+ { default: () => null },
)
}
diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index f0386e923..ce2e389cf 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -1,19 +1,39 @@
/**
- * Removes trailing slashes from URLs.
+ * Adds trailing slashes to URLs.
*
- * This middleware only runs in development to maintain consistent behavior.
- * In production, Vercel handles this redirect via vercel.json.
+ * We use middleware to correctly handle all the cases, since Vercel doesn't handle it properly for nuxt app.
*
- * - /package/vue/ → /package/vue
- * - /docs/getting-started/?query=value → /docs/getting-started?query=value
+ * - /package/vue → /package/vue/
+ * - /docs/getting-started?query=value → /docs/getting-started/?query=value
*/
export default defineNuxtRouteMiddleware(to => {
- if (!import.meta.dev) return
+ // request is marked as prerender for build and server ISR
+ // ignore rewrite for build prerender only
+ if (import.meta.prerender && process.env.NUXT_STAGE === 'build') return
- if (to.path !== '/' && to.path.endsWith('/')) {
+ // ignore for package-code (file viewer shouldn't relate on trailing slash) and api routes
+ if (
+ to.path.startsWith('/package-code/') ||
+ to.path.startsWith('/api/') ||
+ to.path.endsWith('/_payload.json')
+ )
+ return
+
+ if (import.meta.server) {
+ const event = useRequestEvent()
+
+ // requests to /page-path/_payload.json are handled with to.path="/page-path", so we use the original url to check properly
+ const urlRaw = event?.node.req.originalUrl || event?.node.req.url || ''
+ const originalUrl = new URL(urlRaw, 'http://npmx.dev')
+
+ if (originalUrl.pathname.includes('/_payload.json') || originalUrl.pathname.endsWith('/'))
+ return
+ }
+
+ if (to.path !== '' && !to.path.endsWith('/')) {
return navigateTo(
{
- path: to.path.slice(0, -1),
+ path: to.path + '/',
query: to.query,
hash: to.hash,
},
diff --git a/app/pages/accessibility.vue b/app/pages/accessibility.vue
index 8f3750fb2..18f8089a0 100644
--- a/app/pages/accessibility.vue
+++ b/app/pages/accessibility.vue
@@ -58,6 +58,7 @@ const canGoBack = useCanGoBack()
diff --git a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
index 2202494d7..8b3d787ab 100644
--- a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
+++ b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
@@ -313,6 +313,7 @@ defineOgImageComponent('Default', {
@@ -360,6 +362,7 @@ defineOgImageComponent('Default', {
/
diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue
index 07e6dbe92..1331f5c61 100644
--- a/app/pages/package-docs/[...path].vue
+++ b/app/pages/package-docs/[...path].vue
@@ -137,6 +137,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
@@ -196,6 +197,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index de6c114d9..8ba1ce144 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -195,18 +195,9 @@ const { data: skillsData } = useLazyFetch(
const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
const { data: moduleReplacement } = useModuleReplacement(packageName)
-const {
- data: resolvedVersion,
- status: versionStatus,
- error: versionError,
-} = await useResolvedVersion(packageName, requestedVersion)
-
-if (
- versionStatus.value === 'error' &&
- versionError.value?.statusCode &&
- versionError.value.statusCode >= 400 &&
- versionError.value.statusCode < 500
-) {
+const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion)
+
+if (resolvedVersion.value === null) {
throw createError({
statusCode: 404,
statusMessage: $t('package.not_found'),
diff --git a/app/pages/privacy.vue b/app/pages/privacy.vue
index c0e93030a..742d72e47 100644
--- a/app/pages/privacy.vue
+++ b/app/pages/privacy.vue
@@ -139,6 +139,7 @@ const { locale } = useI18n()
@@ -267,6 +268,7 @@ const { locale } = useI18n()
diff --git a/app/pages/~[username]/orgs.vue b/app/pages/~[username]/orgs.vue
index 138bd6913..236030cab 100644
--- a/app/pages/~[username]/orgs.vue
+++ b/app/pages/~[username]/orgs.vue
@@ -136,6 +136,7 @@ defineOgImageComponent('Default', {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 2a3629aa3..76e01eef6 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -86,11 +86,13 @@ export default defineNuxtConfig({
url: 'https://npmx.dev',
name: 'npmx',
description: 'A fast, modern browser for the npm registry',
+ trailingSlash: true,
},
router: {
options: {
scrollBehaviorType: 'smooth',
+ strict: true,
},
},
@@ -133,14 +135,10 @@ export default defineNuxtConfig({
},
},
// pages
- '/package/:name': getISRConfig(60, { fallback: 'html' }),
- '/package/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
- '/package/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
- '/package/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
- '/package/:org/:name': getISRConfig(60, { fallback: 'html' }),
- '/package/:org/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
- '/package/:org/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
- '/package/:org/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
+ '/package/:name/': getISRConfig(60),
+ '/package/:name/v/:version/': getISRConfig(3600),
+ '/package/:org/:name/': getISRConfig(60),
+ '/package/:org/:name/v/:version/': getISRConfig(3600),
// infinite cache (versioned - doesn't change)
'/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
@@ -374,8 +372,6 @@ function getISRConfig(expirationSeconds: number, options: ISRConfigOptions = {})
}
}
return {
- isr: {
- expiration: expirationSeconds,
- },
+ isr: expirationSeconds,
}
}
diff --git a/package.json b/package.json
index 2e6782357..7cc6232cd 100644
--- a/package.json
+++ b/package.json
@@ -10,9 +10,9 @@
"url": "https://roe.dev"
},
"scripts": {
- "build": "nuxt build",
+ "build": "NUXT_STAGE=build nuxt build",
"build:lunaria": "node ./lunaria/lunaria.ts",
- "build:test": "NODE_ENV=test pnpm build",
+ "build:test": "NUXT_STAGE=build NODE_ENV=test pnpm build",
"dev": "nuxt dev",
"dev:docs": "pnpm run --filter npmx-docs dev --port=3001",
"i18n:check": "node scripts/compare-translations.ts",
diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts
index 84cd6035e..8eefa1466 100644
--- a/test/e2e/connector.spec.ts
+++ b/test/e2e/connector.spec.ts
@@ -18,7 +18,7 @@ test.describe.configure({ mode: 'serial' })
* This helper waits for the packages link to appear as proof of successful connection.
*/
async function expectConnected(page: Page, username = 'testuser') {
- await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({
+ await expect(page.locator(`a[href="/~${username}/"]`, { hasText: 'packages' })).toBeVisible({
timeout: 10_000,
})
}
@@ -79,7 +79,7 @@ test.describe('Connector Connection', () => {
await modal.getByRole('button', { name: /close/i }).click()
// The "packages" link should disappear since we're disconnected
- await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({
+ await expect(page.locator('a[href="/~testuser/"]', { hasText: 'packages' })).not.toBeVisible({
timeout: 5000,
})
@@ -257,7 +257,7 @@ test.describe('Package Access Controls', () => {
async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise) {
await gotoConnected('/')
await expectConnected(page)
- await page.goto('/package/@nuxt/kit')
+ await page.goto('/package/@nuxt/kit/')
await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 })
}
diff --git a/test/e2e/create-command.spec.ts b/test/e2e/create-command.spec.ts
index 15f00ee4c..9bb2cb1f7 100644
--- a/test/e2e/create-command.spec.ts
+++ b/test/e2e/create-command.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
test.describe('Create Command', () => {
test.describe('Visibility', () => {
test('/vite - should show create command (same maintainers)', async ({ page, goto }) => {
- await goto('/package/vite', { waitUntil: 'domcontentloaded' })
+ await goto('/package/vite/', { waitUntil: 'domcontentloaded' })
// Create command section should be visible (SSR)
// Use specific container to avoid matching README code blocks
@@ -12,14 +12,14 @@ test.describe('Create Command', () => {
await expect(createCommandSection.locator('code')).toContainText(/create vite/i)
// Link to create-vite should be present (uses sr-only text, so check attachment not visibility)
- await expect(page.locator('a[href="/package/create-vite"]').first()).toBeAttached()
+ await expect(page.locator('a[href="/package/create-vite/"]').first()).toBeAttached()
})
test('/next - should show create command (shared maintainer, same repo)', async ({
page,
goto,
}) => {
- await goto('/package/next', { waitUntil: 'domcontentloaded' })
+ await goto('/package/next/', { waitUntil: 'domcontentloaded' })
// Create command section should be visible (SSR)
// Use specific container to avoid matching README code blocks
@@ -28,14 +28,14 @@ test.describe('Create Command', () => {
await expect(createCommandSection.locator('code')).toContainText(/create next-app/i)
// Link to create-next-app should be present (uses sr-only text, so check attachment not visibility)
- await expect(page.locator('a[href="/package/create-next-app"]').first()).toBeAttached()
+ await expect(page.locator('a[href="/package/create-next-app/"]').first()).toBeAttached()
})
test('/nuxt - should show create command (same maintainer, same org)', async ({
page,
goto,
}) => {
- await goto('/package/nuxt', { waitUntil: 'domcontentloaded' })
+ await goto('/package/nuxt/', { waitUntil: 'domcontentloaded' })
// Create command section should be visible (SSR)
// nuxt has create-nuxt package, so command is "npm create nuxt"
@@ -49,7 +49,7 @@ test.describe('Create Command', () => {
page,
goto,
}) => {
- await goto('/package/is-odd', { waitUntil: 'domcontentloaded' })
+ await goto('/package/is-odd/', { waitUntil: 'domcontentloaded' })
// Wait for package to load
await expect(page.locator('h1').filter({ hasText: 'is-odd' })).toBeVisible()
@@ -63,7 +63,7 @@ test.describe('Create Command', () => {
test.describe('Copy Functionality', () => {
test('hovering create command shows copy button', async ({ page, goto }) => {
- await goto('/package/vite', { waitUntil: 'hydration' })
+ await goto('/package/vite/', { waitUntil: 'hydration' })
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
@@ -94,7 +94,7 @@ test.describe('Create Command', () => {
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
- await goto('/package/vite', { waitUntil: 'hydration' })
+ await goto('/package/vite/', { waitUntil: 'hydration' })
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
@@ -124,7 +124,7 @@ test.describe('Create Command', () => {
test.describe('Install Command Copy', () => {
test('hovering install command shows copy button', async ({ page, goto }) => {
- await goto('/package/is-odd', { waitUntil: 'hydration' })
+ await goto('/package/is-odd/', { waitUntil: 'hydration' })
// Find the install command container
const installCommandContainer = page.locator('.group\\/installcmd').first()
@@ -149,7 +149,7 @@ test.describe('Create Command', () => {
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
- await goto('/package/is-odd', { waitUntil: 'hydration' })
+ await goto('/package/is-odd/', { waitUntil: 'hydration' })
// Find and hover over the install command container
const installCommandContainer = page.locator('.group\\/installcmd').first()
diff --git a/test/e2e/docs.spec.ts b/test/e2e/docs.spec.ts
index f95a23351..aa456799d 100644
--- a/test/e2e/docs.spec.ts
+++ b/test/e2e/docs.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
test.describe('API Documentation Pages', () => {
test('docs page loads and shows content for a package', async ({ page, goto }) => {
// Use a small, stable package with TypeScript types
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
// Page title should include package name
await expect(page).toHaveTitle(/ufo.*docs/i)
@@ -24,7 +24,7 @@ test.describe('API Documentation Pages', () => {
})
test('docs page shows TOC sidebar on desktop', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
// TOC sidebar should be visible (on desktop viewport)
const tocSidebar = page.locator('aside')
@@ -38,7 +38,7 @@ test.describe('API Documentation Pages', () => {
})
test('TOC links navigate to sections', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
// Click on Functions in TOC
const functionsLink = page.locator('aside a[href="#section-function"]')
@@ -53,7 +53,7 @@ test.describe('API Documentation Pages', () => {
})
test('clicking symbol name scrolls to symbol', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
// Find a symbol link in the TOC
const symbolLink = page.locator('aside a[href^="#function-"]').first()
@@ -67,27 +67,27 @@ test.describe('API Documentation Pages', () => {
})
test('docs page without version redirects to latest', async ({ page, goto }) => {
- await goto('/package-docs/ufo', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/', { waitUntil: 'networkidle' })
// Should redirect to include version
await expect(page).toHaveURL(/\/package-docs\/ufo\/v\//)
})
test('package link in header navigates to package page', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
// Click on package name in header
const packageLink = page.locator('header a').filter({ hasText: 'ufo' })
await packageLink.click()
// Should navigate to package page (URL ends with /ufo)
- await expect(page).toHaveURL(/\/package\/ufo$/)
+ await expect(page).toHaveURL(/\/package\/ufo\/$/)
})
test('docs page handles package gracefully when types unavailable', async ({ page, goto }) => {
// Use a simple JS package - the page should load without crashing
// regardless of whether it has types or shows an error state
- await goto('/package-docs/is-odd/v/3.0.1', { waitUntil: 'networkidle' })
+ await goto('/package-docs/is-odd/v/3.0.1/', { waitUntil: 'networkidle' })
// Header should always show the package name
await expect(page.locator('header').getByText('is-odd')).toBeVisible()
@@ -105,7 +105,7 @@ test.describe('API Documentation Pages', () => {
test.describe('Version Selector', () => {
test('version selector dropdown shows versions', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
// Find and click the version selector button (wait for it to be visible)
const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
@@ -123,7 +123,7 @@ test.describe('Version Selector', () => {
})
test('selecting a version navigates to that version', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
// Find and click the version selector button (wait for it to be visible)
const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
@@ -157,7 +157,7 @@ test.describe('Version Selector', () => {
})
test('escape key closes version dropdown', async ({ page, goto }) => {
- await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+ await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
// Wait for version button to be visible
const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts
index 0f1c6ae9e..a79fd8674 100644
--- a/test/e2e/hydration.spec.ts
+++ b/test/e2e/hydration.spec.ts
@@ -3,13 +3,13 @@ import { expect, test } from './test-utils'
const PAGES = [
'/',
- '/about',
- '/settings',
- '/privacy',
- '/compare',
- '/search',
- '/package/nuxt',
- '/search?q=vue',
+ '/about/',
+ '/settings/',
+ '/privacy/',
+ '/compare/',
+ '/search/',
+ '/package/nuxt/',
+ '/search/?q=vue',
] as const
// ---------------------------------------------------------------------------
diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index b900bbafa..16a93afc3 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from './test-utils'
test.describe('Compare Page', () => {
test('no-dep column renders separately from package columns', async ({ page, goto }) => {
- await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+ await goto('/compare/?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
const grid = page.locator('.comparison-grid')
await expect(grid).toBeVisible({ timeout: 15000 })
@@ -20,7 +20,7 @@ test.describe('Compare Page', () => {
goto,
}) => {
// Start with vue and no-dep
- await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+ await goto('/compare/?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
const grid = page.locator('.comparison-grid')
await expect(grid).toBeVisible({ timeout: 15000 })
@@ -50,8 +50,8 @@ test.describe('Compare Page', () => {
})
test.describe('Search Pages', () => {
- test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
- await goto('/search?q=vue', { waitUntil: 'hydration' })
+ test('/search/?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
+ await goto('/search/?q=vue', { waitUntil: 'hydration' })
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
timeout: 15000,
@@ -73,8 +73,8 @@ test.describe('Search Pages', () => {
await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
})
- test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
- await goto('/search?q=vue', { waitUntil: 'hydration' })
+ test('/search/?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
+ await goto('/search/?q=vue', { waitUntil: 'hydration' })
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
timeout: 15000,
@@ -108,8 +108,8 @@ test.describe('Search Pages', () => {
await expect(headerSearchInput).toBeFocused()
})
- test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
- await goto('/settings', { waitUntil: 'hydration' })
+ test('/settings/ → search, keeps focus on search input', async ({ page, goto }) => {
+ await goto('/settings/', { waitUntil: 'hydration' })
const searchInput = page.locator('input[type="search"]')
await expect(searchInput).toBeVisible()
@@ -130,7 +130,7 @@ test.describe('Search Pages', () => {
test.describe('Keyboard Shortcuts', () => {
test('"c" navigates to /compare', async ({ page, goto }) => {
- await goto('/settings', { waitUntil: 'hydration' })
+ await goto('/settings/', { waitUntil: 'hydration' })
await page.keyboard.press('c')
@@ -138,7 +138,7 @@ test.describe('Keyboard Shortcuts', () => {
})
test('"c" does not navigate when any modifier key is pressed', async ({ page, goto }) => {
- await goto('/settings', { waitUntil: 'hydration' })
+ await goto('/settings/', { waitUntil: 'hydration' })
await page.keyboard.press('Shift+c')
await expect(page).toHaveURL(/\/settings/)
@@ -156,16 +156,16 @@ test.describe('Keyboard Shortcuts', () => {
page,
goto,
}) => {
- await goto('/package/vue', { waitUntil: 'hydration' })
+ await goto('/package/vue/', { waitUntil: 'hydration' })
await page.keyboard.press('c')
// Should navigate to /compare with the package in the query
- await expect(page).toHaveURL(/\/compare\?packages=vue/)
+ await expect(page).toHaveURL(/\/compare\/\?packages=vue/)
})
test('"c" does not navigate when search input is focused', async ({ page, goto }) => {
- await goto('/settings', { waitUntil: 'hydration' })
+ await goto('/settings/', { waitUntil: 'hydration' })
const searchInput = page.locator('#header-search')
await searchInput.focus()
@@ -174,7 +174,7 @@ test.describe('Keyboard Shortcuts', () => {
await page.keyboard.press('c')
// Should still be on settings, not navigated to compare
- await expect(page).toHaveURL(/\/settings/)
+ await expect(page).toHaveURL(/\/settings\//)
// The 'c' should have been typed into the input
await expect(searchInput).toHaveValue('c')
})
@@ -183,7 +183,7 @@ test.describe('Keyboard Shortcuts', () => {
page,
goto,
}) => {
- await goto('/package/vue', { waitUntil: 'hydration' })
+ await goto('/package/vue/', { waitUntil: 'hydration' })
await page.keyboard.press('Shift+c')
await expect(page).toHaveURL(/\/vue/)
@@ -198,7 +198,7 @@ test.describe('Keyboard Shortcuts', () => {
})
test('"," navigates to /settings', async ({ page, goto }) => {
- await goto('/compare', { waitUntil: 'hydration' })
+ await goto('/compare/', { waitUntil: 'hydration' })
await page.keyboard.press(',')
@@ -206,7 +206,7 @@ test.describe('Keyboard Shortcuts', () => {
})
test('"," does not navigate when any modifier key is pressed', async ({ page, goto }) => {
- await goto('/settings', { waitUntil: 'hydration' })
+ await goto('/settings/', { waitUntil: 'hydration' })
const searchInput = page.locator('#header-search')
await searchInput.focus()
@@ -225,7 +225,7 @@ test.describe('Keyboard Shortcuts', () => {
})
test('"," does not navigate when search input is focused', async ({ page, goto }) => {
- await goto('/compare', { waitUntil: 'hydration' })
+ await goto('/compare/', { waitUntil: 'hydration' })
const searchInput = page.locator('#header-search')
await searchInput.focus()
diff --git a/test/e2e/package-manager-select.spec.ts b/test/e2e/package-manager-select.spec.ts
index e5a185f74..8c5111c5c 100644
--- a/test/e2e/package-manager-select.spec.ts
+++ b/test/e2e/package-manager-select.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from './test-utils'
test.describe('Package Page', () => {
test('/vue → package manager select dropdown works', async ({ page, goto }) => {
- await goto('/package/vue', { waitUntil: 'hydration' })
+ await goto('/package/vue/', { waitUntil: 'hydration' })
await expect(page.locator('h1')).toContainText('vue', { timeout: 15000 })
diff --git a/test/e2e/url-compatibility.spec.ts b/test/e2e/url-compatibility.spec.ts
index b555d7605..993d0e2cd 100644
--- a/test/e2e/url-compatibility.spec.ts
+++ b/test/e2e/url-compatibility.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
test.describe('npmjs.com URL Compatibility', () => {
test.describe('Package Pages', () => {
test('/package/vue → package page', async ({ page, goto }) => {
- await goto('/package/vue', { waitUntil: 'domcontentloaded' })
+ await goto('/package/vue/', { waitUntil: 'domcontentloaded' })
// Should show package name
await expect(page.locator('h1')).toContainText('vue')
@@ -12,14 +12,14 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/package/@nuxt/kit → scoped package page', async ({ page, goto }) => {
- await goto('/package/@nuxt/kit', { waitUntil: 'domcontentloaded' })
+ await goto('/package/@nuxt/kit/', { waitUntil: 'domcontentloaded' })
// Should show scoped package name
await expect(page.locator('h1')).toContainText('@nuxt/kit')
})
test('/package/vue/v/3.5.27 → specific version', async ({ page, goto }) => {
- await goto('/package/vue/v/3.5.27', { waitUntil: 'domcontentloaded' })
+ await goto('/package/vue/v/3.5.27/', { waitUntil: 'domcontentloaded' })
// Should show package name
await expect(page.locator('h1')).toContainText('vue')
@@ -31,7 +31,7 @@ test.describe('npmjs.com URL Compatibility', () => {
page,
goto,
}) => {
- await goto('/package/@nuxt/kit/v/3.20.0', { waitUntil: 'domcontentloaded' })
+ await goto('/package/@nuxt/kit/v/3.20.0/', { waitUntil: 'domcontentloaded' })
// Should show scoped package name
await expect(page.locator('h1')).toContainText('@nuxt/kit')
@@ -40,7 +40,7 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/package/nonexistent-pkg-12345 → 404 handling', async ({ page, goto }) => {
- await goto('/package/nonexistent-pkg-12345', { waitUntil: 'domcontentloaded' })
+ await goto('/package/nonexistent-pkg-12345/', { waitUntil: 'domcontentloaded' })
// Should show error state - look for the heading specifically
await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible()
@@ -49,7 +49,7 @@ test.describe('npmjs.com URL Compatibility', () => {
test.describe('Search Pages', () => {
test('/search?q=vue → search results', async ({ page, goto }) => {
- await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
+ await goto('/search/?q=vue', { waitUntil: 'domcontentloaded' })
// Should show search input with query
await expect(page.locator('input[type="search"]')).toHaveValue('vue')
@@ -58,7 +58,7 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/search?q=keywords:framework → keyword search', async ({ page, goto }) => {
- await goto('/search?q=keywords:framework', { waitUntil: 'domcontentloaded' })
+ await goto('/search/?q=keywords:framework', { waitUntil: 'domcontentloaded' })
// Should show search input with query
await expect(page.locator('input[type="search"]')).toHaveValue('keywords:framework')
@@ -67,7 +67,7 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/search → empty search page', async ({ page, goto }) => {
- await goto('/search', { waitUntil: 'domcontentloaded' })
+ await goto('/search/', { waitUntil: 'domcontentloaded' })
// Should show empty state prompt
await expect(page.locator('text=/start typing/i')).toBeVisible()
@@ -76,7 +76,7 @@ test.describe('npmjs.com URL Compatibility', () => {
test.describe('User Profile Pages', () => {
test('/~qwerzl → user profile', async ({ page, goto }) => {
- await goto('/~qwerzl', { waitUntil: 'hydration' })
+ await goto('/~qwerzl/', { waitUntil: 'hydration' })
// Should show username
await expect(page.locator('h1')).toContainText('~qwerzl')
@@ -87,7 +87,7 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/~nonexistent-user-12345 → empty user handling', async ({ page, goto }) => {
- await goto('/~nonexistent-user-12345', { waitUntil: 'domcontentloaded' })
+ await goto('/~nonexistent-user-12345/', { waitUntil: 'domcontentloaded' })
// Should show username in header
await expect(page.locator('h1')).toContainText('~nonexistent-user-12345')
@@ -98,7 +98,7 @@ test.describe('npmjs.com URL Compatibility', () => {
test.describe('Organization Pages', () => {
test('/org/nuxt → organization page', async ({ page, goto }) => {
- await goto('/org/nuxt', { waitUntil: 'domcontentloaded' })
+ await goto('/org/nuxt/', { waitUntil: 'domcontentloaded' })
// Should show org name
await expect(page.locator('h1')).toContainText('@nuxt')
@@ -107,7 +107,7 @@ test.describe('npmjs.com URL Compatibility', () => {
})
test('/org/nonexistent-org-12345 → 404 handling', async ({ page, goto }) => {
- await goto('/org/nonexistent-org-12345', { waitUntil: 'domcontentloaded' })
+ await goto('/org/nonexistent-org-12345/', { waitUntil: 'domcontentloaded' })
// Should show 404 error page
await expect(page.locator('h1')).toContainText('Organization not found')
@@ -116,19 +116,19 @@ test.describe('npmjs.com URL Compatibility', () => {
test.describe('Edge Cases', () => {
test('package name with dots: /package/lodash.merge', async ({ page, goto }) => {
- await goto('/package/lodash.merge', { waitUntil: 'domcontentloaded' })
+ await goto('/package/lodash.merge/', { waitUntil: 'domcontentloaded' })
await expect(page.locator('h1')).toContainText('lodash.merge')
})
test('package name with hyphens: /package/is-odd', async ({ page, goto }) => {
- await goto('/package/is-odd', { waitUntil: 'domcontentloaded' })
+ await goto('/package/is-odd/', { waitUntil: 'domcontentloaded' })
await expect(page.locator('h1')).toContainText('is-odd')
})
test('scoped package with hyphens: /package/@types/node', async ({ page, goto }) => {
- await goto('/package/@types/node', { waitUntil: 'domcontentloaded' })
+ await goto('/package/@types/node/', { waitUntil: 'domcontentloaded' })
await expect(page.locator('h1')).toContainText('@types/node')
})
diff --git a/test/nuxt/components/VersionSelector.spec.ts b/test/nuxt/components/VersionSelector.spec.ts
index dc3e2b59b..90efa13e2 100644
--- a/test/nuxt/components/VersionSelector.spec.ts
+++ b/test/nuxt/components/VersionSelector.spec.ts
@@ -322,7 +322,7 @@ describe('VersionSelector', () => {
const versionLink = component.findAll('a').find(a => a.text().includes('1.0.0'))
expect(versionLink?.attributes('href')).toBe(
- '/package-code/test-package/v/1.0.0/src/index.ts',
+ '/package-code/test-package/v/1.0.0/src/index.ts/',
)
})
})
diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts
index 54eece316..01d3b8e9f 100644
--- a/test/nuxt/components/compare/PackageSelector.spec.ts
+++ b/test/nuxt/components/compare/PackageSelector.spec.ts
@@ -40,7 +40,7 @@ describe('PackageSelector', () => {
},
})
- const link = component.find('a[href="/package/lodash"]')
+ const link = component.find('a[href="/package/lodash/"]')
expect(link.exists()).toBe(true)
})
diff --git a/vercel.json b/vercel.json
index a72838625..b84865c1e 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,6 +1,5 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
- "trailingSlash": false,
"redirects": [
{
"source": "/(.*)",