Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7069d10
chore: disable trailing slash rule
alexdln Feb 22, 2026
0a484dd
chore: enable trailing slash in nuxt config
alexdln Feb 22, 2026
f471f07
chore: enable trailing slash via middleware
alexdln Feb 22, 2026
28c6ff0
chore: improve trailing slash middleware
alexdln Feb 22, 2026
63f5f82
chore: improve version loading logic
alexdln Feb 22, 2026
2688747
chore: disable fallback for packages
alexdln Feb 22, 2026
b219252
chore: rewrite version resolver
alexdln Feb 22, 2026
9adacac
chore: disable extra caching for payload routes
alexdln Feb 22, 2026
35d8b2f
Merge branch 'main' into chore/test-route-overriting
alexdln Feb 22, 2026
71fe94d
fix: configure trailing slash for all links
alexdln Feb 22, 2026
1210053
test: add trailing slash in tests
alexdln Feb 22, 2026
07af3d2
test: update tests and rules
alexdln Feb 23, 2026
1c2f241
test: disable default trailing slash for external links
alexdln Feb 23, 2026
8862b9a
test: fix trailing slash in tests
alexdln Feb 23, 2026
2588604
test: fix trailing slash in tests
alexdln Feb 23, 2026
9d32dbb
test: update trailing slash middleware
alexdln Feb 23, 2026
dbcffe1
fix: improve trailing slash detection logic
alexdln Feb 23, 2026
575fa89
fix: restore isr configs for dynamic pages
alexdln Feb 23, 2026
8c3151c
Merge branch 'main' into chore/test-route-overriting
alexdln Feb 23, 2026
e33959f
fix: revert isr configs for dynamic pages
alexdln Feb 23, 2026
570dc33
fix: add trailing slash to dynamic route rules
alexdln Feb 23, 2026
6b6e410
fix: keep paths only with trailing slash
alexdln Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/components/Code/DirectoryListing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const bytesFormatter = useBytesFormatter()
>
<td colspan="2">
<LinkBase
:trailing-slash="node.type === 'directory' ? 'append' : 'remove'"
:to="getCodeRoute(node.path)"
class="py-2 px-4 font-mono text-sm w-full"
no-underline
Expand Down
1 change: 1 addition & 0 deletions app/components/Code/FileTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ watch(
<!-- File -->
<template v-else>
<LinkBase
trailing-slash="remove"
variant="button-secondary"
:to="getFileRoute(node.path)"
:aria-current="currentPath === node.path"
Expand Down
1 change: 1 addition & 0 deletions app/components/DependencyPathPopup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function parsePackageString(pkg: string): { name: string; version: string } {
>
<span v-if="idx > 0" class="text-fg-subtle me-1">└─</span>
<NuxtLink
trailing-slash="append"
:to="
packageRoute(
parsePackageString(pathItem).name,
Expand Down
1 change: 1 addition & 0 deletions app/components/Header/MobileMenu.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ onUnmounted(deactivate)
</span>
<div>
<NuxtLink
trailing-slash="append"
v-for="link in group.items"
:key="link.name"
:to="link.to"
Expand Down
3 changes: 3 additions & 0 deletions app/components/Header/OrgsDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function handleKeydown(event: KeyboardEvent) {
@keydown="handleKeydown"
>
<NuxtLink
trailing-slash="append"
:to="{ name: '~username-orgs', params: { username } }"
class="link-subtle font-mono text-sm inline-flex items-center gap-1"
>
Expand Down Expand Up @@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
<ul v-else-if="orgs.length > 0" class="py-1 max-h-80 overflow-y-auto">
<li v-for="org in orgs" :key="org">
<NuxtLink
trailing-slash="append"
:to="{ name: 'org', params: { org } }"
class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors"
>
Expand All @@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {

<div class="px-3 py-2 border-t border-border">
<NuxtLink
trailing-slash="append"
:to="{ name: '~username-orgs', params: { username } }"
class="link-subtle font-mono text-xs inline-flex items-center gap-1"
>
Expand Down
3 changes: 3 additions & 0 deletions app/components/Header/PackagesDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function handleKeydown(event: KeyboardEvent) {
@keydown="handleKeydown"
>
<NuxtLink
trailing-slash="append"
:to="{ name: '~username', params: { username } }"
class="link-subtle font-mono text-sm inline-flex items-center gap-1"
>
Expand Down Expand Up @@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
<ul v-else-if="packages.length > 0" class="py-1 max-h-80 overflow-y-auto">
<li v-for="pkg in packages" :key="pkg">
<NuxtLink
trailing-slash="append"
:to="packageRoute(pkg)"
class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors truncate"
>
Expand All @@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {

<div class="px-3 py-2 border-t border-border">
<NuxtLink
trailing-slash="append"
:to="{ name: '~username', params: { username } }"
class="link-subtle font-mono text-xs inline-flex items-center gap-1"
>
Expand Down
3 changes: 3 additions & 0 deletions app/components/Link/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
)
Expand Down Expand Up @@ -74,6 +76,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && !isLink.value)
<NuxtLink
v-bind="props"
v-else
:trailing-slash="isLinkExternal ? undefined : (trailingSlash ?? 'append')"
class="group/link gap-x-1 items-center"
:class="{
'flex': block,
Expand Down
1 change: 1 addition & 0 deletions app/components/Org/MembersPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ watch(lastExecutionTime, () => {
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<NuxtLink
trailing-slash="append"
:to="{ name: '~username', params: { username: member.name } }"
class="font-mono text-sm text-fg hover:text-fg transition-colors duration-200"
>
Expand Down
1 change: 1 addition & 0 deletions app/components/Org/TeamsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<NuxtLink
trailing-slash="append"
:to="{ name: '~username', params: { username: user } }"
class="font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200"
>
Expand Down
1 change: 1 addition & 0 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<NuxtLink
trailing-slash="append"
:to="packageRoute(result.package.name)"
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
Expand Down
2 changes: 2 additions & 0 deletions app/components/Package/ClaimPackageModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const previewPackageJson = computed(() => {

<div class="flex gap-3">
<NuxtLink
trailing-slash="append"
:to="packageRoute(packageName)"
class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70"
@click="close"
Expand Down Expand Up @@ -289,6 +290,7 @@ const previewPackageJson = computed(() => {
<span v-else class="w-4 h-4 shrink-0" />
<div class="min-w-0">
<NuxtLink
trailing-slash="append"
:to="packageRoute(pkg.name)"
class="font-mono text-sm text-fg hover:underline focus-visible:outline-accent/70 rounded"
target="_blank"
Expand Down
1 change: 1 addition & 0 deletions app/components/Package/DeprecatedTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function getDepthStyle(depth: DependencyDepth) {
<DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />

<NuxtLink
trailing-slash="append"
:to="packageRoute(pkg.name, pkg.version)"
class="font-mono text-sm font-medium hover:underline truncate py-4"
:class="getDepthStyle(pkg.depth).text"
Expand Down
2 changes: 2 additions & 0 deletions app/components/Package/TableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const allMaintainersText = computed(() => {
<!-- Name (always visible) -->
<td class="py-2 px-3">
<NuxtLink
trailing-slash="append"
:to="packageUrl"
class="row-link font-mono text-sm text-fg hover:text-accent-fallback transition-colors duration-200"
dir="ltr"
Expand Down Expand Up @@ -107,6 +108,7 @@ const allMaintainersText = computed(() => {
:key="maintainer.username || maintainer.email"
>
<NuxtLink
trailing-slash="append"
:to="{
name: '~username',
params: { username: maintainer.username || maintainer.name || '' },
Expand Down
2 changes: 2 additions & 0 deletions app/components/Package/VulnerabilityTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function getDepthStyle(depth: string | undefined) {
<DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />

<NuxtLink
trailing-slash="append"
:to="packageRoute(pkg.name, pkg.version)"
class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0"
:class="getDepthStyle(pkg.depth).text"
Expand Down Expand Up @@ -160,6 +161,7 @@ function getDepthStyle(depth: string | undefined) {
<span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
<NuxtLink
v-if="vuln.fixedIn"
trailing-slash="append"
:to="packageRoute(pkg.name, vuln.fixedIn)"
class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400 hover:underline"
:title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
Expand Down
1 change: 1 addition & 0 deletions app/components/SearchSuggestionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defineProps<{
<template>
<BaseCard :isExactMatch="isExactMatch">
<NuxtLink
trailing-slash="append"
:to="
type === 'user'
? { name: '~username', params: { username: name } }
Expand Down
2 changes: 2 additions & 0 deletions app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ const copyDevInstallCommand = () =>
></code
>
<NuxtLink
trailing-slash="append"
:to="packageRoute(typesPackageName!)"
class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-accent/70 rounded select-none"
:title="$t('package.get_started.view_types', { package: typesPackageName })"
Expand Down Expand Up @@ -268,6 +269,7 @@ const copyDevInstallCommand = () =>
:text="$t('package.create.view', { packageName: createPackageInfo.packageName })"
>
<NuxtLink
trailing-slash="append"
:to="packageRoute(createPackageInfo.packageName)"
class="inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 text-fg-muted hover:text-fg text-xs transition-colors focus-visible:outline-2 focus-visible:outline-accent/70 rounded"
>
Expand Down
3 changes: 3 additions & 0 deletions app/components/VersionSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ watch(

<!-- Version link -->
<NuxtLink
trailing-slash="append"
:to="getVersionUrl(group.primaryVersion.version)"
class="flex-1 truncate hover:text-fg transition-colors"
@click="isOpen = false"
Expand Down Expand Up @@ -583,6 +584,7 @@ watch(
>
<template v-for="v in group.versions.slice(1)" :key="v.version">
<NuxtLink
trailing-slash="append"
:id="`version-${v.version}`"
:to="getVersionUrl(v.version)"
role="option"
Expand Down Expand Up @@ -622,6 +624,7 @@ watch(
<!-- Link to package page for full version list -->
<div class="border-t border-border mt-1 pt-1 px-3 py-2">
<NuxtLink
trailing-slash="append"
:to="packageRoute(packageName)"
class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
@click="isOpen = false"
Expand Down
18 changes: 10 additions & 8 deletions app/composables/npm/useResolvedVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ export function useResolvedVersion(
packageName: MaybeRefOrGetter<string>,
requestedVersion: MaybeRefOrGetter<string | null>,
) {
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<ResolvedPackageVersion>(url)
return data.version
},
{ default: () => null },
)
}
36 changes: 28 additions & 8 deletions app/middleware/trailing-slash.global.ts
Original file line number Diff line number Diff line change
@@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions app/pages/accessibility.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const canGoBack = useCanGoBack()
<i18n-t keypath="a11y.approach.p2" tag="span" scope="global">
<template #about>
<NuxtLink
trailing-slash="append"
:to="{ name: 'about' }"
class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ defineOgImageComponent('Default', {
<!-- Package info and navigation -->
<div class="flex items-center gap-2 mb-3 flex-wrap min-w-0">
<NuxtLink
trailing-slash="append"
:to="packageRoute(packageName, version)"
class="font-mono text-lg font-medium hover:text-fg transition-colors min-w-0 truncate max-w-[60vw] sm:max-w-none"
:title="packageName"
Expand Down Expand Up @@ -350,6 +351,7 @@ defineOgImageComponent('Default', {
>
<NuxtLink
v-if="filePath"
trailing-slash="append"
:to="getCurrentCodeUrlWithPath()"
class="text-fg-muted hover:text-fg transition-colors shrink-0"
>
Expand All @@ -360,6 +362,7 @@ defineOgImageComponent('Default', {
<span class="text-fg-subtle">/</span>
<NuxtLink
v-if="i < breadcrumbs.length - 1"
trailing-slash="append"
:to="getCurrentCodeUrlWithPath(crumb.path)"
class="text-fg-muted hover:text-fg transition-colors"
>
Expand Down
2 changes: 2 additions & 0 deletions app/pages/package-docs/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
<div class="flex items-center gap-3 min-w-0">
<NuxtLink
v-if="packageName"
trailing-slash="append"
:to="packageRoute(packageName)"
class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate"
>
Expand Down Expand Up @@ -196,6 +197,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
<div class="flex gap-4 mt-4">
<NuxtLink
v-if="packageName"
trailing-slash="append"
:to="packageRoute(packageName)"
class="link-subtle font-mono text-sm"
>
Expand Down
15 changes: 3 additions & 12 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -195,18 +195,9 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>(
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'),
Expand Down
2 changes: 2 additions & 0 deletions app/pages/privacy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const { locale } = useI18n()
</template>
<template #settings>
<NuxtLink
trailing-slash="append"
:to="{ name: 'settings' }"
class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
>
Expand Down Expand Up @@ -267,6 +268,7 @@ const { locale } = useI18n()
<i18n-t keypath="privacy_policy.authenticated.p2" tag="span" scope="global">
<template #settings>
<NuxtLink
trailing-slash="append"
:to="{ name: 'settings' }"
class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
>
Expand Down
Loading
Loading