diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue
index 6955352350..3548b118aa 100644
--- a/app/components/AppFooter.vue
+++ b/app/components/AppFooter.vue
@@ -20,27 +20,19 @@ const closeModal = () => modalRef.value?.close?.()
-
-
- {{ $t('footer.about') }}
-
-
- {{ $t('footer.blog') }}
-
-
- {{ $t('privacy_policy.title') }}
-
-
- {{ $t('a11y.footer_title') }}
-
-
- {{ $t('translation_status.title') }}
-
+
+
+
+
+ {{
+ $t('footer.product')
+ }}
+ {{ $t('footer.about') }}
+ {{ $t('footer.blog') }}
+ {{ $t('footer.docs') }}
-
-
- {{ $t('footer.docs') }}
-
-
- {{ $t('footer.source') }}
-
-
- {{ $t('footer.social') }}
-
-
- {{ discord.label }}
-
+
+
+
+ {{
+ $t('footer.legal')
+ }}
+ {{ $t('privacy_policy.title') }}
+ {{ $t('a11y.footer_title') }}
+ {{
+ $t('translation_status.title')
+ }}
+
+
+
+
+ {{
+ $t('footer.community')
+ }}
+ {{ $t('footer.source') }}
+ {{ $t('footer.social') }}
+ {{ discord.label }}
diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue
index 8f0cb2290c..76148d115f 100644
--- a/app/components/AppHeader.vue
+++ b/app/components/AppHeader.vue
@@ -151,6 +151,7 @@ const route = useRoute()
const isMobile = useIsMobile()
const isSearchExpandedManually = shallowRef(false)
const searchBoxRef = useTemplateRef('searchBoxRef')
+const searchContainerRef = useTemplateRef('searchContainerRef')
// On search page, always show search expanded on mobile
const isOnHomePage = computed(() => route.name === 'index')
@@ -188,6 +189,12 @@ function handleSearchBlur() {
}
}
+onClickOutside(searchContainerRef, () => {
+ if (isMobile.value && !isOnSearchPage.value) {
+ isSearchExpandedManually.value = false
+ }
+})
+
function handleSearchFocus() {
showFullSearch.value = true
}
@@ -215,7 +222,7 @@ onKeyStroke(
diff --git a/app/pages/blog/index.vue b/app/pages/blog/index.vue
index ef824f0637..d582c964ca 100644
--- a/app/pages/blog/index.vue
+++ b/app/pages/blog/index.vue
@@ -21,9 +21,12 @@ useSeoMeta({
+
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 83b0f2b86b..4bb2930835 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -108,11 +108,27 @@ function prefetchReadmeMarkdown() {
}
async function copyReadmeHandler() {
- await fetchReadmeMarkdown()
+ // Safari requires navigator.clipboard.write() to be called synchronously within
+ // the user gesture, but accepts a Promise inside ClipboardItem.
+ // This pattern works in Safari 13.1+, Chrome, and Firefox.
+ if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
+ const blobPromise = (async () => {
+ await fetchReadmeMarkdown()
+ const markdown = readmeMarkdownData.value?.markdown ?? ''
+ return new Blob([markdown], { type: 'text/plain' })
+ })()
+ try {
+ await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blobPromise })])
+ return
+ } catch {
+ // Fall through to legacy approach
+ }
+ }
+ // Legacy fallback (non-Safari, or older browsers)
+ await fetchReadmeMarkdown()
const markdown = readmeMarkdownData.value?.markdown
if (!markdown) return
-
await copyReadme(markdown)
}
@@ -305,6 +321,17 @@ const latestVersion = computed(() => {
return pkg.value.versions[latestTag] ?? null
})
+// Detect license changes between current version and latest
+// SlimVersion does not carry license, so we compare against the package-level license
+const licenseChanged = computed(() => {
+ const currentLicense = displayVersion.value?.license
+ const latestLicense = pkg.value?.license
+ if (!currentLicense || !latestLicense) return false
+ const normalize = (l: unknown): string =>
+ typeof l === 'string' ? l : ((l as { type?: string })?.type ?? '')
+ return normalize(currentLicense) !== normalize(latestLicense)
+})
+
const deprecationNotice = computed(() => {
if (!displayVersion.value?.deprecated) return null
@@ -367,18 +394,6 @@ const sizeTooltip = computed(() => {
return chunks.filter(Boolean).join('\n')
})
-const hasDependencies = computed(() => {
- if (!displayVersion.value) return false
- const deps = displayVersion.value.dependencies
- const peerDeps = displayVersion.value.peerDependencies
- const optionalDeps = displayVersion.value.optionalDependencies
- return (
- (deps && Object.keys(deps).length > 0) ||
- (peerDeps && Object.keys(peerDeps).length > 0) ||
- (optionalDeps && Object.keys(optionalDeps).length > 0)
- )
-})
-
// Vulnerability count for the stats banner
const vulnCount = computed(() => vulnTree.value?.totalCounts.total ?? 0)
const hasVulnerabilities = computed(() => vulnCount.value > 0)
@@ -582,9 +597,25 @@ const showSkeleton = shallowRef(false)
{{ $t('package.stats.license') }}
-
-
+
+
{{ $t('package.license.none') }}
+
+
+
+ {{ $t('package.license.changed_badge') }}
+
+
@@ -959,7 +990,7 @@ const showSkeleton = shallowRef(false)
+definePageMeta({
+ name: 'package-dependents',
+ scrollMargin: 200,
+})
+
+const route = useRoute('package-dependents')
+
+const packageName = computed(() => {
+ const { org, name } = route.params
+ return org ? `${org}/${name}` : name
+})
+
+const { data: pkg } = usePackage(packageName)
+
+const resolvedVersion = computed(() => {
+ const latest = pkg.value?.['dist-tags']?.latest
+ if (!latest) return null
+ return latest
+})
+
+const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
+
+const latestVersion = computed(() => {
+ if (!pkg.value) return null
+ const latestTag = pkg.value['dist-tags']?.latest
+ if (!latestTag) return null
+ return pkg.value.versions[latestTag] ?? null
+})
+
+const versionUrlPattern = computed(() => {
+ const split = packageName.value.split('/')
+ if (split.length === 2) {
+ return `/package/${split[0]}/${split[1]}/v/{version}`
+ }
+ return `/package/${packageName.value}/v/{version}`
+})
+
+const page = shallowRef(0)
+const PAGE_SIZE = 20
+
+interface DependentsResponse {
+ total: number
+ page: number
+ size: number
+ packages: Array<{
+ name: string
+ version: string
+ description: string | null
+ date: string | null
+ score: number
+ }>
+}
+
+const { data, status, refresh } = useLazyFetch(
+ () => `/api/registry/dependents/${packageName.value}`,
+ {
+ query: computed(() => ({ page: page.value, size: PAGE_SIZE })),
+ watch: [page],
+ },
+)
+
+const totalPages = computed(() => {
+ if (!data.value?.total) return 0
+ return Math.ceil(data.value.total / PAGE_SIZE)
+})
+
+function prevPage() {
+ if (page.value > 0) {
+ page.value--
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+}
+
+function nextPage() {
+ if (page.value < totalPages.value - 1) {
+ page.value++
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+}
+
+const numberFormatter = useNumberFormatter()
+
+useSeoMeta({
+ title: () => `Dependents - ${packageName.value} - npmx`,
+ description: () => `Packages that depend on ${packageName.value}`,
+})
+
+
+
+
+
+
+
+
+ {{ $t('package.dependents.title') }}
+
+
+ {{ $t('package.dependents.subtitle', { name: packageName }) }}
+
+
+
+
+
+
+
+
+
+
{{ $t('package.dependents.error') }}
+
{{ $t('common.retry') }}
+
+
+
+
+
+
{{ $t('package.dependents.none', { name: packageName }) }}
+
+
+
+
+
+ {{
+ $t(
+ 'package.dependents.count',
+ { count: numberFormatter.format(data.total) },
+ data.total,
+ )
+ }}
+
+
+
+ -
+
+
+
+ {{ dep.name }}
+
+
+ {{ dep.description }}
+
+
+
+ {{ dep.version }}
+
+
+
+
+
+
+
+
+ {{ $t('common.previous') }}
+
+ {{ page + 1 }} / {{ totalPages }}
+
+ {{ $t('common.next') }}
+
+
+
+
+
+
+
diff --git a/app/pages/search.vue b/app/pages/search.vue
index b306359ca7..58fac08c02 100644
--- a/app/pages/search.vue
+++ b/app/pages/search.vue
@@ -265,6 +265,18 @@ const effectiveTotal = computed(() => {
return displayResults.value.length
})
+/**
+ * Total items for pagination purposes.
+ * Capped by EAGER_LOAD_SIZE so that the page count only reflects pages we can
+ * actually fetch — e.g. with a 500-result cap, max pages = ceil(500 / pageSize).
+ * Without this cap, a search returning total=92,000 would show 3,680 pages but
+ * navigation beyond page 20 (at 25/page) would silently fail.
+ */
+const paginationTotal = computed(() => {
+ const cap = EAGER_LOAD_SIZE[searchProvider.value]
+ return Math.min(effectiveTotal.value, cap)
+})
+
// Handle filter chip removal
function handleClearFilter(chip: FilterChip) {
clearFilter(chip)
@@ -878,7 +890,7 @@ onBeforeUnmount(() => {
v-model:mode="paginationMode"
v-model:page-size="preferredPageSize"
v-model:current-page="currentPage"
- :total-items="effectiveTotal"
+ :total-items="paginationTotal"
:view-mode="viewMode"
/>
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 02c1af000f..ea215a481f 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -19,7 +19,10 @@
"social": "social",
"chat": "chat",
"builders_chat": "builders",
- "keyboard_shortcuts": "keyboard shortcuts"
+ "keyboard_shortcuts": "keyboard shortcuts",
+ "product": "Product",
+ "legal": "Legal",
+ "community": "Community"
},
"shortcuts": {
"section": {
@@ -207,6 +210,8 @@
"members": "members"
},
"scroll_to_top": "Scroll to top",
+ "previous": "Previous",
+ "next": "Next",
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
@@ -318,7 +323,8 @@
"docs": "docs",
"fund": "fund",
"compare": "compare",
- "compare_this_package": "compare this package"
+ "compare_this_package": "compare this package",
+ "dependents": "dependents"
},
"likes": {
"like": "Like this package",
@@ -352,6 +358,13 @@
"title": "Run",
"locally": "Run locally"
},
+ "dependents": {
+ "title": "Dependents",
+ "subtitle": "Packages that depend on {name}",
+ "count": "{count} dependent | {count} dependents",
+ "none": "No packages found that depend on {name}",
+ "error": "Failed to load dependents"
+ },
"readme": {
"title": "Readme",
"no_readme": "No README available.",
@@ -454,7 +467,8 @@
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements",
- "vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities"
+ "vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities",
+ "none": "No dependencies"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
@@ -564,7 +578,9 @@
},
"license": {
"view_spdx": "View license text on SPDX",
- "none": "None"
+ "none": "None",
+ "changed_badge": "changed",
+ "changed": "License changed from {latest} in the latest version"
},
"vulnerabilities": {
"tree_found": "{vulns} vulnerability in {packages}/{total} packages | {vulns} vulnerabilities in {packages}/{total} packages",
@@ -840,7 +856,8 @@
},
"file_path": "File path",
"binary_file": "Binary file",
- "binary_rendering_warning": "File type \"{contentType}\" is not supported for preview."
+ "binary_rendering_warning": "File type \"{contentType}\" is not supported for preview.",
+ "toggle_word_wrap": "Word wrap"
},
"badges": {
"provenance": {
diff --git a/i18n/schema.json b/i18n/schema.json
index 885bd6eb5e..e8a1dc4117 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -63,6 +63,15 @@
},
"keyboard_shortcuts": {
"type": "string"
+ },
+ "product": {
+ "type": "string"
+ },
+ "legal": {
+ "type": "string"
+ },
+ "community": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -625,6 +634,12 @@
"scroll_to_top": {
"type": "string"
},
+ "previous": {
+ "type": "string"
+ },
+ "next": {
+ "type": "string"
+ },
"cancel": {
"type": "string"
},
@@ -960,6 +975,9 @@
},
"compare_this_package": {
"type": "string"
+ },
+ "dependents": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -1060,6 +1078,27 @@
},
"additionalProperties": false
},
+ "dependents": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "subtitle": {
+ "type": "string"
+ },
+ "count": {
+ "type": "string"
+ },
+ "none": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
"readme": {
"type": "object",
"properties": {
@@ -1368,6 +1407,9 @@
},
"vulnerabilities_count": {
"type": "string"
+ },
+ "none": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -1698,6 +1740,12 @@
},
"none": {
"type": "string"
+ },
+ "changed_badge": {
+ "type": "string"
+ },
+ "changed": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -2526,6 +2574,9 @@
},
"binary_rendering_warning": {
"type": "string"
+ },
+ "toggle_word_wrap": {
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts
new file mode 100644
index 0000000000..bb87717159
--- /dev/null
+++ b/server/api/registry/dependents/[...pkg].get.ts
@@ -0,0 +1,87 @@
+import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants'
+
+const NPM_SEARCH_BASE = 'https://registry.npmjs.org/-/v1/search'
+
+interface NpmSearchResult {
+ objects: Array<{
+ package: {
+ name: string
+ version: string
+ description?: string
+ date?: string
+ links?: {
+ npm?: string
+ homepage?: string
+ repository?: string
+ }
+ }
+ score: {
+ final: number
+ }
+ searchScore: number
+ }>
+ total: number
+ time: string
+}
+
+/**
+ * GET /api/registry/dependents/:name
+ *
+ * Returns packages that depend on the given package,
+ * using the npm search API with `dependencies: