+
{{ pkg }}
{
id="package-search"
v-model="inputValue"
type="text"
+ :aria-describedby="versionHintId"
:placeholder="
packages.length === 0
? $t('compare.selector.search_first')
@@ -314,6 +324,23 @@ onClickOutside(containerRef, () => {
+
+
+ package@version
+
+
+ vue@3.0.0
+
+
+ vue@3.5.44
+
+
+
{{
$t('compare.selector.packages_selected', {
diff --git a/app/components/Package/DailyDownloadsHeatmap.vue b/app/components/Package/DailyDownloadsHeatmap.vue
index 719fdac178..ab4bdef01b 100644
--- a/app/components/Package/DailyDownloadsHeatmap.vue
+++ b/app/components/Package/DailyDownloadsHeatmap.vue
@@ -15,13 +15,7 @@ import { applyBlocklistCorrection } from '~/utils/download-anomalies'
import { downloadFileLink } from '~/utils/download'
import { useCopyChartPng } from '~/composables/useCopyChartPng'
import { useElementSize } from '@vueuse/core'
-import {
- sanitise,
- copyAltTextFromDownloadsHeatmap,
- CHART_ANNOTATOR_SLOTS,
- getAnnotatorIcon,
- getAnnotatorStyle,
-} from '~/utils/charts'
+import { sanitise, copyAltTextFromDownloadsHeatmap, CHART_ANNOTATOR_SLOTS } from '~/utils/charts'
type DailyDataPoint = {
day: string
@@ -298,13 +292,14 @@ const config = computed(() => ({
useCursorPointer: true,
buttons: {
pdf: false,
- fullscreen: false,
+ fullscreen: true,
table: false,
tooltip: false,
altCopy: true,
annotator: !isMobile.value,
},
buttonTitles: {
+ fullscreen: $t('package.trends.toggle_fullscreen'),
csv: $t('package.trends.download_file', { fileType: 'CSV' }),
img: $t('package.trends.download_file', { fileType: 'PNG' }),
svg: $t('package.trends.download_file', { fileType: 'SVG' }),
@@ -750,6 +745,9 @@ function getLegendContent(svg: VueUiHeatmapSvgSlotProps['svg']) {
@click="copyChartPng"
/>
+
+
+
PNG
@@ -769,16 +767,7 @@ function getLegendContent(svg: VueUiHeatmapSvgSlotProps['svg']) {
-
+
diff --git a/app/components/Package/Install/Dropdown.vue b/app/components/Package/Install/Dropdown.vue
index d93eb784a8..d5ebca4aef 100644
--- a/app/components/Package/Install/Dropdown.vue
+++ b/app/components/Package/Install/Dropdown.vue
@@ -77,6 +77,7 @@ onPrehydrate(el => {
{{ $t('package.get_started.title') }}
diff --git a/app/components/Package/SecurityDowngradeAlert.vue b/app/components/Package/SecurityDowngradeAlert.vue
index 7bd2ddf91a..c0aca05692 100644
--- a/app/components/Package/SecurityDowngradeAlert.vue
+++ b/app/components/Package/SecurityDowngradeAlert.vue
@@ -2,6 +2,7 @@
import type { PublishSecurityDowngrade } from '~/utils/publish-security'
defineProps<{
+ packageName: string
downgrade: PublishSecurityDowngrade | null
fallbackInstallText?: string | null
}>()
@@ -119,5 +120,15 @@ defineProps<{
{{ fallbackInstallText }}
+
+ {{ $t('compare.file_changes') }}
+
diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue
index 645b7687f8..89f27934ed 100644
--- a/app/components/Package/TimelineChart.vue
+++ b/app/components/Package/TimelineChart.vue
@@ -23,8 +23,6 @@ import {
type TimelineSizeCacheValue,
CHART_ANNOTATOR_SLOTS,
E18E_GRADIENT_COLORS,
- getAnnotatorIcon,
- getAnnotatorStyle,
type TimelineChartMetric,
type StackbarTooltipPoint,
type TimelinePlotItem,
@@ -430,6 +428,7 @@ const commonConfig = computed(() => ({
},
},
buttonTitles: {
+ fullscreen: $t('package.trends.toggle_fullscreen'),
csv: $t('package.trends.download_file', { fileType: 'CSV' }),
img: $t('package.trends.download_file', { fileType: 'PNG' }),
svg: $t('package.trends.download_file', { fileType: 'SVG' }),
@@ -535,7 +534,7 @@ const config = computed(() => {
buttons: {
pdf: false,
labels: false,
- fullscreen: false,
+ fullscreen: true,
table: false,
tooltip: false,
altCopy: true,
@@ -729,7 +728,7 @@ const stackbarConfig = computed(() => ({
buttons: {
pdf: false,
labels: false,
- fullscreen: false,
+ fullscreen: true,
table: false,
tooltip: false,
annotator: !isMobile.value,
@@ -851,6 +850,9 @@ const sort = usePermalink<'time' | 'semver'>('sort', 'semver')
// "Stable only" filter shared with the page + list via the query string.
const stableOnly = useTimelineStableOnly()
+// "Frozen history" toggle for the size metrics
+const frozenHistory = useTimelineFrozenHistory()
+
const timelineSortOptions = computed(() => [
{ value: 'time' as const, label: $t('package.timeline.chart.sort_time') },
{ value: 'semver' as const, label: $t('package.timeline.chart.sort_semver') },
@@ -934,6 +936,10 @@ const timelineMetricTabs = computed(() => [
v-model="stableOnly"
:label="$t('package.timeline.chart.ordered_versions')"
/>
+
[
+
+
+
PNG
@@ -1038,16 +1047,7 @@ const timelineMetricTabs = computed(() => [
-
+
@@ -1127,6 +1127,9 @@ const timelineMetricTabs = computed(() => [
+
+
+
PNG
@@ -1146,16 +1149,7 @@ const timelineMetricTabs = computed(() => [
-
+
diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue
index 3cffb9e4b0..09acdd2877 100644
--- a/app/components/Package/TrendsChart.vue
+++ b/app/components/Package/TrendsChart.vue
@@ -16,7 +16,12 @@ import type {
import { DATE_INPUT_MAX } from '~/utils/input'
import { endDateOnlyToUtcMs } from '~/utils/chart-data-prediction'
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
-import { copyAltTextForTrendLineChart, sanitise, applyEllipsis } from '~/utils/charts'
+import {
+ CHART_ANNOTATOR_SLOTS,
+ copyAltTextForTrendLineChart,
+ sanitise,
+ applyEllipsis,
+} from '~/utils/charts'
import {
buildNormalisedTrendsDataset,
buildTrendsChartConfig,
@@ -2146,6 +2151,9 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
@click="copyChartPng"
/>
+
+
+
PNG
@@ -2165,72 +2173,8 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
(() => ({
buttons: {
pdf: false,
labels: false,
- fullscreen: false,
+ fullscreen: true,
table: false,
tooltip: false,
altCopy: true,
sort: false,
},
buttonTitles: {
+ fullscreen: $t('package.trends.toggle_fullscreen'),
csv: $t('package.trends.download_file', { fileType: 'CSV' }),
img: $t('package.trends.download_file', { fileType: 'PNG' }),
svg: $t('package.trends.download_file', { fileType: 'SVG' }),
@@ -449,6 +455,9 @@ const barConfig = computed(() => ({
@click="copyChartPng"
/>
+
+
+
PNG
@@ -456,79 +465,9 @@ const barConfig = computed(() => ({
SVG
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
(import.meta.client && isVisible.value ? window : undefined))
+const documentTarget = computed(() =>
+ import.meta.client && isVisible.value ? document : undefined,
+)
+useEventListener(windowTarget, 'blur', hideImmediately)
+useEventListener(windowTarget, 'pagehide', hideImmediately)
+useEventListener(documentTarget, 'visibilitychange', () => {
+ if (document.hidden) hideImmediately()
+})
+
const tooltipAttrs = computed(() => {
const attrs: Record = { role: 'tooltip', id: tooltipId, ...props.tooltipAttr }
if (props.interactive) {
@@ -61,7 +91,7 @@ const tooltipAttrs = computed(() => {
:tooltip-attr="tooltipAttrs"
@mouseenter="show"
@mouseleave="hide"
- @focusin="show"
+ @focusin="showFromFocus"
@focusout="hide"
:aria-describedby="isVisible ? tooltipId : undefined"
>
diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts
index fb3fdb87f6..3184c6b3d7 100644
--- a/app/composables/useCachedFetch.ts
+++ b/app/composables/useCachedFetch.ts
@@ -1,5 +1,4 @@
import type { CachedFetchResult } from '#shared/utils/fetch-cache-config'
-import { defu } from 'defu'
/**
* Get the cachedFetch function from the current request context.
@@ -30,19 +29,18 @@ import { defu } from 'defu'
* ```
*/
export function useCachedFetch(): CachedFetchFunction {
+ const defaultFetch: CachedFetchFunction = async (
+ url: string,
+ options: Parameters[1] = {},
+ _ttl: number = FETCH_CACHE_DEFAULT_TTL,
+ ): Promise> => {
+ const data = (await $fetch(url, options)) as T
+ return { data, isStale: false, cachedAt: null }
+ }
+
// On client, return a function that just uses $fetch (no caching, not stale)
if (import.meta.client) {
- return async (
- url: string,
- options: Parameters[1] = {},
- _ttl: number = FETCH_CACHE_DEFAULT_TTL,
- ): Promise> => {
- const defaultFetchOptions: Parameters[1] = {
- cache: 'force-cache',
- }
- const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T
- return { data, isStale: false, cachedAt: null }
- }
+ return defaultFetch
}
// On server, get the cachedFetch from request context
@@ -56,15 +54,5 @@ export function useCachedFetch(): CachedFetchFunction {
// Fallback: return a function that uses regular $fetch
// (shouldn't happen in normal operation)
- return async (
- url: string,
- options: Parameters[1] = {},
- _ttl: number = FETCH_CACHE_DEFAULT_TTL,
- ): Promise> => {
- const defaultFetchOptions: Parameters[1] = {
- cache: 'force-cache',
- }
- const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T
- return { data, isStale: false, cachedAt: null }
- }
+ return defaultFetch
}
diff --git a/app/composables/useCompareReplacements.ts b/app/composables/useCompareReplacements.ts
index eda1d9f356..c578e06c5a 100644
--- a/app/composables/useCompareReplacements.ts
+++ b/app/composables/useCompareReplacements.ts
@@ -1,4 +1,6 @@
import type { ModuleReplacement, ModuleReplacementMapping } from 'module-replacements'
+import { NO_DEPENDENCY_ID } from '#shared/utils/compare'
+import { parsePackageSpec } from '#shared/utils/parse-package-param'
export interface ReplacementSuggestion {
forPackage: string
@@ -31,6 +33,12 @@ export function useCompareReplacements(packageNames: MaybeRefOrGetter)
const replacements = shallowRef(new Map())
const loading = shallowRef(false)
+ const basePackages = computed(() =>
+ [...new Set(packages.value.map(p => parsePackageSpec(p).name))].filter(
+ name => name !== NO_DEPENDENCY_ID,
+ ),
+ )
+
// Fetch replacements for all packages
async function fetchReplacements(names: string[]) {
if (names.length === 0) return
@@ -70,7 +78,7 @@ export function useCompareReplacements(packageNames: MaybeRefOrGetter)
// Watch for package changes and fetch replacements
if (import.meta.client) {
watch(
- packages,
+ basePackages,
newPackages => {
fetchReplacements(newPackages)
},
@@ -82,7 +90,7 @@ export function useCompareReplacements(packageNames: MaybeRefOrGetter)
const allSuggestions = computed(() => {
const result: ReplacementSuggestion[] = []
- for (const pkg of packages.value) {
+ for (const pkg of basePackages.value) {
const replacement = replacements.value.get(pkg)
if (!replacement) continue
diff --git a/app/composables/useInstallSizeDiff.ts b/app/composables/useInstallSizeDiff.ts
index 86ba7db912..a2fa3b7e6e 100644
--- a/app/composables/useInstallSizeDiff.ts
+++ b/app/composables/useInstallSizeDiff.ts
@@ -50,22 +50,35 @@ export function useInstallSizeDiff(
return getComparisonVersion(pkgVal, version)
})
+ function frozenSizeUrl(version: string): string {
+ return `/api/registry/install-size/${toValue(packageName)}/v/${version}?frozen-history=true`
+ }
+
+ // `latest` resolves the same either way, so its size can be reused as-is
+ // rather than fetched a second time.
+ const currentIsLatest = computed(() => {
+ const version = toValue(resolvedVersion)
+ return !!version && toValue(pkg)?.['dist-tags']?.latest === version
+ })
+
const {
data: comparisonInstallSize,
status: comparisonStatus,
execute: fetchComparisonSize,
- } = useLazyFetch(
- () => {
- const v = comparisonVersion.value
- if (!v) return ''
- return `/api/registry/install-size/${toValue(packageName)}/v/${v}`
- },
- {
+ } = useLazyFetch(() => frozenSizeUrl(comparisonVersion.value ?? ''), {
+ server: false,
+ immediate: false,
+ watch: false,
+ default: () => null,
+ })
+
+ const { data: frozenCurrentInstallSize, execute: fetchFrozenCurrentSize } =
+ useLazyFetch(() => frozenSizeUrl(toValue(resolvedVersion) ?? ''), {
server: false,
immediate: false,
+ watch: false,
default: () => null,
- },
- )
+ })
if (import.meta.client) {
watch(
@@ -75,10 +88,27 @@ export function useInstallSizeDiff(
},
{ immediate: true },
)
+
+ // Watch the current version too: two prereleases can share a comparison
+ // version, and the size of the previous one would be discarded by `diff`.
+ watch(
+ [
+ comparisonVersion,
+ () => toValue(resolvedVersion),
+ () => toValue(packageName),
+ currentIsLatest,
+ ],
+ ([v, , , isLatest]) => {
+ if (v && !isLatest) fetchFrozenCurrentSize()
+ },
+ { immediate: true },
+ )
}
const diff = computed(() => {
- const current = toValue(currentInstallSize)
+ const current = currentIsLatest.value
+ ? toValue(currentInstallSize)
+ : frozenCurrentInstallSize.value
const previous = comparisonInstallSize.value
const cv = comparisonVersion.value
diff --git a/app/composables/useMarkdown.ts b/app/composables/useMarkdown.ts
index bcf8678096..9c8ce5dcf0 100644
--- a/app/composables/useMarkdown.ts
+++ b/app/composables/useMarkdown.ts
@@ -4,6 +4,10 @@ interface UseMarkdownOptions {
text: string
/** When true, renders link text without the anchor tag (useful when inside another link) */
plain?: boolean
+ /** When true, inline code matching an npm package name links to its package page */
+ linkifyPackages?: boolean
+ /** Package name whose inline-code mention stays unlinked (the current package's own name) */
+ excludePackage?: string
}
export function useMarkdown(options: MaybeRefOrGetter) {
@@ -56,6 +60,15 @@ function stripAndEscapeHtml(text: string): string {
(match, codeSpan: string | undefined) => codeSpan ?? '',
)
+ // Strip unclosed HTML tags left by registry truncation (no closing '>').
+ // Only a bare tag name at the very end that starts a word, or one followed by an attribute ('=' or a quote),
+ // counts, so plain text like `x`="']*[="'][^>`]*$/gi,
+ (match, codeSpan: string | undefined) => codeSpan ?? '',
+ )
+
// Strip HTML comments: (including unclosed comments from truncation)
stripped = stripped.replace(
/(`[^`]*`)||$)/g,
@@ -69,10 +82,24 @@ function stripAndEscapeHtml(text: string): string {
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
+ .trim()
}
+// npm package name: optional @scope/ prefix, letters/digits/hyphen/dot/underscore.
+// Content reaching this check is already HTML-escaped, and the pattern excludes `&`,
+// so escaped entities can never match.
+const PACKAGE_NAME_PATTERN = /^(?:@\w[\w.-]*\/)?\w[\w.-]*$/
+
+// Version numbers such as `2.0.0`, `4.x` or `v3` also look like package names, but name a version.
+const VERSION_LIKE_PATTERN = /^v\d+$|^v?\d+(?:\.(?:\d+|x|\*))+(?:-[\w.]+)?$/i
+
// Parse simple inline markdown to HTML
-function parseMarkdown({ text, plain }: UseMarkdownOptions): string {
+function parseMarkdown({
+ text,
+ plain,
+ linkifyPackages,
+ excludePackage,
+}: UseMarkdownOptions): string {
if (!text) return ''
// First strip HTML tags and escape remaining HTML
@@ -86,8 +113,19 @@ function parseMarkdown({ text, plain }: UseMarkdownOptions): string {
html = html.replace(/(?$1')
html = html.replace(/\b_(.+?)_\b/g, '$1')
- // Inline code: `code`
- html = html.replace(/`([^`]+)`/g, '$1')
+ // Inline code: `code` — optionally link package names to their package page
+ html = html.replace(/`([^`]+)`/g, (_match, code: string) => {
+ if (
+ linkifyPackages &&
+ !plain &&
+ code !== excludePackage &&
+ PACKAGE_NAME_PATTERN.test(code) &&
+ !VERSION_LIKE_PATTERN.test(code)
+ ) {
+ return `${code}`
+ }
+ return `${code}`
+ })
// Strikethrough: ~~text~~
html = html.replace(/~~(.+?)~~/g, '$1')
diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts
index a26e1de515..4ff4de7dbf 100644
--- a/app/composables/usePackageComparison.ts
+++ b/app/composables/usePackageComparison.ts
@@ -1,9 +1,7 @@
+import { NO_DEPENDENCY_ID } from '#shared/utils/compare'
import { parsePackageSpec } from '#shared/utils/parse-package-param'
import { getDependencyCount } from '~/utils/npm/dependency-count'
-/** Special identifier for the "What Would James Do?" comparison column */
-export const NO_DEPENDENCY_ID = '__no_dependency__'
-
/**
* Special display values for the "no dependency" column.
* These are explicit markers that get special rendering treatment.
diff --git a/app/composables/useTimelineFrozenHistory.ts b/app/composables/useTimelineFrozenHistory.ts
new file mode 100644
index 0000000000..af8005797a
--- /dev/null
+++ b/app/composables/useTimelineFrozenHistory.ts
@@ -0,0 +1,24 @@
+import { useRouteQuery } from '@vueuse/router'
+
+/**
+ * "Frozen history" toggle for the package timeline's size metrics, saved in
+ * the query string (`?frozen-history=false`).
+ *
+ * - When `true` (default for the timeline view), the versions of the dependencies
+ * of each version are limited to those that were available before the next version
+ * of this package was published.
+ * - When `false`, the dependencies of each version are resolved to the latest
+ * versions that satisfies their ranges, as if it was installed today.
+ */
+export function useTimelineFrozenHistory(): WritableComputedRef {
+ const frozenParam = useRouteQuery('frozen-history', undefined)
+
+ return computed({
+ get() {
+ return frozenParam.value !== 'false'
+ },
+ set(value) {
+ frozenParam.value = value ? undefined : 'false'
+ },
+ })
+}
diff --git a/app/pages/blog/index.vue b/app/pages/blog/index.vue
index ef824f0637..3bd4cb7320 100644
--- a/app/pages/blog/index.vue
+++ b/app/pages/blog/index.vue
@@ -15,6 +15,23 @@ useSeoMeta({
ogDescription: () => $t('blog.meta_description'),
twitterDescription: () => $t('blog.meta_description'),
})
+
+useHead({
+ link: [
+ {
+ rel: 'alternate',
+ title: 'Blog - npmx',
+ type: 'application/rss+xml',
+ href: 'https://npmx.dev/blog/rss.xml',
+ },
+ {
+ rel: 'alternate',
+ title: 'Blog - npmx',
+ type: 'application/atom+xml',
+ href: 'https://npmx.dev/blog/atom.xml',
+ },
+ ],
+})
diff --git a/app/pages/compare.vue b/app/pages/compare.vue
index 1b8df11a90..6b7394adf0 100644
--- a/app/pages/compare.vue
+++ b/app/pages/compare.vue
@@ -1,5 +1,4 @@