From 0bf2f5378665245f8a99bde3f65fadd558b683b8 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:56:51 +0000 Subject: [PATCH 01/19] feat: add package timeline --- app/components/Package/Header.vue | 34 ++++- .../[[org]]/[packageName].vue | 137 ++++++++++++++++++ i18n/locales/en.json | 6 +- i18n/schema.json | 12 ++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 app/pages/package-timeline/[[org]]/[packageName].vue diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 76a756b4dc..374184e743 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -13,7 +13,7 @@ const props = defineProps<{ latestVersion?: SlimVersion | null provenanceData?: ProvenanceDetails | null provenanceStatus?: string | null - page: 'main' | 'docs' | 'code' | 'diff' + page: 'main' | 'docs' | 'code' | 'diff' | 'timeline' versionUrlPattern: string }>() @@ -124,6 +124,19 @@ const diffLink = computed((): RouteLocationRaw | null => { return diffRoute(props.pkg.name, props.resolvedVersion, props.latestVersion.version) }) +const timelineLink = computed((): RouteLocationRaw | null => { + if (props.pkg == null || props.resolvedVersion == null) return null + const split = props.pkg.name.split('/') + return { + name: 'timeline', + params: { + org: split.length === 2 ? split[0] : undefined, + packageName: split.length === 2 ? split[1]! : split[0]!, + version: props.resolvedVersion, + }, + } +}) + const keyboardShortcuts = useKeyboardShortcuts() onKeyStroke( @@ -178,6 +191,16 @@ onKeyStroke( { dedupe: true }, ) +onKeyStroke( + e => keyboardShortcuts.value && isKeyWithoutModifiers(e, 't') && !isEditableElement(e.target), + e => { + if (timelineLink.value === null) return + e.preventDefault() + navigateTo(timelineLink.value) + }, + { dedupe: true }, +) + //atproto // TODO: Maybe set this where it's not loaded here every load? const { user } = useAtproto() @@ -426,6 +449,15 @@ const likeAction = async () => { > {{ $t('compare.compare_versions') }} + + {{ $t('package.links.timeline') }} + diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue new file mode 100644 index 0000000000..4cc6530961 --- /dev/null +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -0,0 +1,137 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4d241f522d..fd1e5e4d70 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -301,7 +301,8 @@ "code": "code", "docs": "docs", "fund": "fund", - "compare": "compare" + "compare": "compare", + "timeline": "timeline" }, "likes": { "like": "Like this package", @@ -420,6 +421,9 @@ "version_filter_label": "Filter versions", "no_match_filter": "No versions match {filter}" }, + "timeline": { + "load_more": "Load more" + }, "dependencies": { "title": "Dependency ({count}) | Dependencies ({count})", "list_label": "Package dependencies", diff --git a/i18n/schema.json b/i18n/schema.json index ff41df4289..f237809872 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -909,6 +909,9 @@ }, "compare": { "type": "string" + }, + "timeline": { + "type": "string" } }, "additionalProperties": false @@ -1264,6 +1267,15 @@ }, "additionalProperties": false }, + "timeline": { + "type": "object", + "properties": { + "load_more": { + "type": "string" + } + }, + "additionalProperties": false + }, "dependencies": { "type": "object", "properties": { From 97e094aa3658bdaf54ccabac4e93def51b218714 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:59:52 +0000 Subject: [PATCH 02/19] feat: add package size and dependency changes --- .../[[org]]/[packageName].vue | 159 +++++++++++++++++- i18n/locales/en.json | 6 +- i18n/schema.json | 12 ++ 3 files changed, 173 insertions(+), 4 deletions(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 4cc6530961..38cab480fb 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -41,14 +41,19 @@ const timelineEntries = computed(() => { const time = pkg.value.time const versions = Object.keys(pkg.value.versions) + const tagsByVersion = new Map() + for (const [tag, ver] of Object.entries(pkg.value['dist-tags'] ?? {})) { + const list = tagsByVersion.get(ver) + if (list) list.push(tag) + else tagsByVersion.set(ver, [tag]) + } + return versions .filter(v => time[v]) .map(v => ({ version: v, time: time[v]!, - tags: Object.entries(pkg.value!['dist-tags'] ?? {}) - .filter(([, ver]) => ver === v) - .map(([tag]) => tag), + tags: tagsByVersion.get(v) ?? [], })) .sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) }) @@ -62,6 +67,88 @@ function loadMore() { visibleCount.value += PAGE_SIZE } +const SIZE_INCREASE_THRESHOLD = 0.25 +const DEP_INCREASE_THRESHOLD = 5 + +const sizeCache = shallowReactive(new Map()) +const fetchingVersions = shallowReactive(new Set()) + +async function fetchSize(ver: string) { + if (sizeCache.has(ver) || fetchingVersions.has(ver)) return + fetchingVersions.add(ver) + try { + const data = await $fetch( + `/api/registry/install-size/${packageName.value}/v/${ver}`, + ) + sizeCache.set(ver, data) + } catch { + // silently skip — size data is best-effort + } finally { + fetchingVersions.delete(ver) + } +} + +// Fetch sizes for visible version pairs +if (import.meta.client) { + watch( + visibleEntries, + entries => { + for (const entry of entries) { + fetchSize(entry.version) + } + }, + { immediate: true }, + ) +} + +interface SizeEvent { + direction: 'increase' | 'decrease' + sizeRatio: number + sizeDelta: number + depDiff: number + sizeThresholdExceeded: boolean + depThresholdExceeded: boolean +} + +// Compute size events between consecutive visible versions +const sizeEvents = computed(() => { + const events = new Map() + const entries = visibleEntries.value + + for (let i = 0; i < entries.length - 1; i++) { + const current = sizeCache.get(entries[i]!.version) + const previous = sizeCache.get(entries[i + 1]!.version) + if (!current || !previous) continue + + const sizeRatio = + previous.totalSize > 0 ? (current.totalSize - previous.totalSize) / previous.totalSize : 0 + const depDiff = current.dependencyCount - previous.dependencyCount + + const sizeIncreased = sizeRatio > SIZE_INCREASE_THRESHOLD + const sizeDecreased = sizeRatio < -SIZE_INCREASE_THRESHOLD + const depsIncreased = depDiff > DEP_INCREASE_THRESHOLD + const depsDecreased = depDiff < -DEP_INCREASE_THRESHOLD + + if (!sizeIncreased && !sizeDecreased && !depsIncreased && !depsDecreased) continue + + events.set(entries[i]!.version, { + direction: + (sizeDecreased || depsDecreased) && !sizeIncreased && !depsIncreased + ? 'decrease' + : 'increase', + sizeRatio, + sizeDelta: current.totalSize - previous.totalSize, + depDiff, + sizeThresholdExceeded: sizeIncreased || sizeDecreased, + depThresholdExceeded: depsIncreased || depsDecreased, + }) + } + + return events +}) + +const bytesFormatter = useBytesFormatter() + useSeoMeta({ title: () => `Timeline - ${packageName.value} - npmx`, description: () => `Version timeline for ${packageName.value}`, @@ -83,6 +170,72 @@ useSeoMeta({
  1. + +
    + + +
    + + + +
    +
    Date: Fri, 20 Mar 2026 06:18:45 +0000 Subject: [PATCH 03/19] feat: add license changes --- app/composables/npm/usePackage.ts | 7 +++ .../[[org]]/[packageName].vue | 47 +++++++++++++++++-- i18n/locales/en.json | 3 +- i18n/schema.json | 3 ++ shared/types/npm-registry.ts | 1 + 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index 50a40e6c52..e1244c9f72 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -86,12 +86,19 @@ export function transformPackument( const trustLevel = getTrustLevel(version) const hasProvenance = trustLevel !== 'none' + // Normalize license: some versions use { type: "MIT" } instead of "MIT" + let versionLicense = version.license + if (versionLicense && typeof versionLicense === 'object' && 'type' in versionLicense) { + versionLicense = (versionLicense as { type: string }).type + } + filteredVersions[v] = { hasProvenance, trustLevel, version: version.version, deprecated: version.deprecated, tags: version.tags as string[], + license: typeof versionLicense === 'string' ? versionLicense : undefined, } } } diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 38cab480fb..da790662d5 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -147,6 +147,27 @@ const sizeEvents = computed(() => { return events }) +// Detect license changes between consecutive versions +const licenseChanges = computed(() => { + const changes = new Map() + const entries = timelineEntries.value + + for (let i = 0; i < entries.length - 1; i++) { + const current = pkg.value?.versions[entries[i]!.version] + const previous = pkg.value?.versions[entries[i + 1]!.version] + if (!current || !previous) continue + + const currentLicense = current.license ?? 'Unknown' + const previousLicense = previous.license ?? 'Unknown' + + if (currentLicense !== previousLicense) { + changes.set(entries[i]!.version, { from: previousLicense, to: currentLicense }) + } + } + + return changes +}) + const bytesFormatter = useBytesFormatter() useSeoMeta({ @@ -190,12 +211,12 @@ useSeoMeta({ aria-hidden="true" /> -
    -
    +

    + + +
    + + +

    + {{ + $t('package.timeline.license_change', { + from: licenseChanges.get(entry.version)!.from, + to: licenseChanges.get(entry.version)!.to, + }) + }} +

    & { hasProvenance?: boolean trustLevel?: PublishTrustLevel + license?: string } /** From 28bb82d7ef7f396db139c42a876e5778305e7a45 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:52:19 +0000 Subject: [PATCH 04/19] feat: add esm change --- app/composables/npm/usePackage.ts | 1 + .../[[org]]/[packageName].vue | 50 +++++++++++++++++++ i18n/locales/en.json | 4 +- i18n/schema.json | 6 +++ shared/types/npm-registry.ts | 2 + 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index e1244c9f72..4223b09bc5 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -99,6 +99,7 @@ export function transformPackument( deprecated: version.deprecated, tags: version.tags as string[], license: typeof versionLicense === 'string' ? versionLicense : undefined, + type: typeof version.type === 'string' ? version.type : undefined, } } } diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index da790662d5..ef5dccc668 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -168,6 +168,29 @@ const licenseChanges = computed(() => { return changes }) +// Detect ESM support changes (package "type" field) between consecutive versions +const esmChanges = computed(() => { + const changes = new Map() + const entries = timelineEntries.value + + for (let i = 0; i < entries.length - 1; i++) { + const current = pkg.value?.versions[entries[i]!.version] + const previous = pkg.value?.versions[entries[i + 1]!.version] + if (!current || !previous) continue + + const currentIsEsm = current.type === 'module' + const previousIsEsm = previous.type === 'module' + + if (currentIsEsm && !previousIsEsm) { + changes.set(entries[i]!.version, 'added') + } else if (!currentIsEsm && previousIsEsm) { + changes.set(entries[i]!.version, 'removed') + } + } + + return changes +}) + const bytesFormatter = useBytesFormatter() useSeoMeta({ @@ -273,6 +296,33 @@ useSeoMeta({ }}

    + +
    + + +

    + {{ + esmChanges.get(entry.version) === 'added' + ? $t('package.timeline.esm_added') + : $t('package.timeline.esm_removed') + }} +

    +
    Date: Fri, 20 Mar 2026 18:57:57 +0000 Subject: [PATCH 05/19] chore: rework design to subbranch version info --- .../[[org]]/[packageName].vue | 224 +++++++++--------- 1 file changed, 115 insertions(+), 109 deletions(-) diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index ef5dccc668..1f1f2c5fa1 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -214,115 +214,6 @@ useSeoMeta({
    1. - -
      - - -

      - - - -

      -
      - -
      - - -

      - {{ - $t('package.timeline.license_change', { - from: licenseChanges.get(entry.version)!.from, - to: licenseChanges.get(entry.version)!.to, - }) - }} -

      -
      - -
      - - -

      - {{ - esmChanges.get(entry.version) === 'added' - ? $t('package.timeline.esm_added') - : $t('package.timeline.esm_removed') - }} -

      -
      + +
        + +
      1. + + +

        + + + +

        +
      2. + +
      3. + + +

        + {{ + $t('package.timeline.license_change', { + from: licenseChanges.get(entry.version)!.from, + to: licenseChanges.get(entry.version)!.to, + }) + }} +

        +
      4. + +
      5. + + +

        + {{ + esmChanges.get(entry.version) === 'added' + ? $t('package.timeline.esm_added') + : $t('package.timeline.esm_removed') + }} +

        +
      6. +
    From 949c209feaa5b4441a8e82ebc500b8d34c4b8129 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:30:56 +0000 Subject: [PATCH 06/19] feat: rework to use an api Do the hard work in the api, fake paging just for sake of making the page nicer. --- .../[[org]]/[packageName].vue | 437 +++++++++--------- i18n/locales/en.json | 4 +- i18n/schema.json | 6 + server/api/registry/timeline/[...pkg].get.ts | 94 ++++ 4 files changed, 334 insertions(+), 207 deletions(-) create mode 100644 server/api/registry/timeline/[...pkg].get.ts diff --git a/app/pages/package-timeline/[[org]]/[packageName].vue b/app/pages/package-timeline/[[org]]/[packageName].vue index 1f1f2c5fa1..0ef5373e4d 100644 --- a/app/pages/package-timeline/[[org]]/[packageName].vue +++ b/app/pages/package-timeline/[[org]]/[packageName].vue @@ -1,5 +1,7 @@