Skip to content

Commit 4b1d372

Browse files
authored
Merge branch 'main' into fix-peer-deps
2 parents d5a195b + f5ae776 commit 4b1d372

96 files changed

Lines changed: 2833 additions & 734 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,4 @@ jobs:
139139
run: pnpm install
140140

141141
- name: 🔍 Check for unused code
142-
run: pnpm knip:production
142+
run: pnpm knip

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 Daniel Roe
3+
Copyright (c) 2026 npmx team and contributors
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

app/components/AppHeader.vue

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const isSearchExpandedManually = shallowRef(false)
2020
const searchBoxRef = shallowRef<{ focus: () => void } | null>(null)
2121
2222
// On search page, always show search expanded on mobile
23+
const isOnHomePage = computed(() => route.name === 'index')
2324
const isOnSearchPage = computed(() => route.name === 'search')
2425
const isSearchExpanded = computed(() => isOnSearchPage.value || isSearchExpandedManually.value)
2526
@@ -72,17 +73,36 @@ onKeyStroke(
7273
},
7374
{ dedupe: true },
7475
)
76+
77+
onKeyStroke(
78+
'c',
79+
e => {
80+
// Allow more specific handlers to take precedence
81+
if (e.defaultPrevented) return
82+
83+
// Don't trigger if user is typing in an input
84+
const target = e.target as HTMLElement
85+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
86+
return
87+
}
88+
89+
e.preventDefault()
90+
navigateTo('/compare')
91+
},
92+
{ dedupe: true },
93+
)
7594
</script>
7695

7796
<template>
7897
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
7998
<nav
8099
:aria-label="$t('nav.main_navigation')"
81-
class="container min-h-14 flex items-center justify-between gap-2"
100+
class="container min-h-14 flex items-center gap-2"
101+
:class="isOnHomePage ? 'justify-end' : 'justify-between'"
82102
>
83103
<!-- Mobile: Logo + search button (expands search, doesn't navigate) -->
84104
<button
85-
v-if="!isSearchExpanded"
105+
v-if="!isSearchExpanded && !isOnHomePage"
86106
type="button"
87107
class="sm:hidden flex-shrink-0 inline-flex items-center gap-2 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
88108
:aria-label="$t('nav.tap_to_search')"
@@ -156,10 +176,16 @@ onKeyStroke(
156176
<!-- Desktop: Compare link -->
157177
<NuxtLink
158178
to="/compare"
159-
class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
179+
class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
180+
aria-keyshortcuts="c"
160181
>
161-
<span class="i-carbon:compare w-4 h-4" aria-hidden="true" />
162182
{{ $t('nav.compare') }}
183+
<kbd
184+
class="inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded"
185+
aria-hidden="true"
186+
>
187+
c
188+
</kbd>
163189
</NuxtLink>
164190

165191
<!-- Desktop: Settings link -->
@@ -185,7 +211,7 @@ onKeyStroke(
185211
<!-- Mobile: Menu button (always visible, toggles menu) -->
186212
<button
187213
type="button"
188-
class="sm:hidden p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
214+
class="sm:hidden flex items-center p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
189215
:aria-label="showMobileMenu ? $t('common.close') : $t('nav.open_menu')"
190216
:aria-expanded="showMobileMenu"
191217
@click="showMobileMenu = !showMobileMenu"

app/components/CollapsibleSection.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ useHead({
123123
:id="contentId"
124124
class="grid ms-6 transition-[grid-template-rows] duration-200 ease-in-out collapsible-content overflow-hidden"
125125
>
126-
<div class="min-h-0">
126+
<div class="min-h-0 min-w-0">
127127
<slot />
128128
</div>
129129
</div>

app/components/MarkdownText.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { decodeHtmlEntities } from '~/utils/formatters'
3+
24
const props = defineProps<{
35
text: string
46
/** When true, renders link text without the anchor tag (useful when inside another link) */
@@ -21,8 +23,11 @@ function stripMarkdownImages(text: string): string {
2123
2224
// Strip HTML tags and escape remaining HTML to prevent XSS
2325
function stripAndEscapeHtml(text: string): string {
24-
// First strip markdown image badges
25-
let stripped = stripMarkdownImages(text)
26+
// First decode any HTML entities in the input
27+
let stripped = decodeHtmlEntities(text)
28+
29+
// Then strip markdown image badges
30+
stripped = stripMarkdownImages(stripped)
2631
2732
// Then strip actual HTML tags (keep their text content)
2833
// Only match tags that start with a letter or / (to avoid matching things like "a < b > c")

app/components/OgImage/Package.vue

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<script setup lang="ts">
2-
withDefaults(
2+
import { computed, toRefs } from 'vue'
3+
4+
const props = withDefaults(
35
defineProps<{
46
name: string
57
version: string
8+
stars: number
69
downloads?: string
710
license?: string
811
primaryColor?: string
@@ -13,6 +16,15 @@ withDefaults(
1316
primaryColor: '#60a5fa',
1417
},
1518
)
19+
20+
const { name, version, stars, downloads, license, primaryColor } = toRefs(props)
21+
22+
const formattedStars = computed(() =>
23+
Intl.NumberFormat('en', {
24+
notation: 'compact',
25+
maximumFractionDigits: 1,
26+
}).format(stars.value),
27+
)
1628
</script>
1729

1830
<template>
@@ -88,6 +100,18 @@ withDefaults(
88100
</span>
89101
</span>
90102
<span v-if="license"> • {{ license }}</span>
103+
<span class="flex items-center gap-2">
104+
<span>•</span>
105+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32px" height="32px">
106+
<path
107+
fill="currentColor"
108+
d="m16 6.52l2.76 5.58l.46 1l1 .15l6.16.89l-4.38 4.3l-.75.73l.18 1l1.05 6.13l-5.51-2.89L16 23l-.93.49l-5.51 2.85l1-6.13l.18-1l-.74-.77l-4.42-4.35l6.16-.89l1-.15l.46-1zM16 2l-4.55 9.22l-10.17 1.47l7.36 7.18L6.9 30l9.1-4.78L25.1 30l-1.74-10.13l7.36-7.17l-10.17-1.48Z"
109+
/>
110+
</svg>
111+
<span>
112+
{{ formattedStars }}
113+
</span>
114+
</span>
91115
</div>
92116
</div>
93117

app/components/PackageCard.vue

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const props = defineProps<{
77
/** Whether to show the publisher username */
88
showPublisher?: boolean
99
prefetch?: boolean
10-
selected?: boolean
1110
index?: number
1211
/** Search query for highlighting exact matches */
1312
searchQuery?: string
@@ -20,17 +19,12 @@ const isExactMatch = computed(() => {
2019
const name = props.result.package.name.toLowerCase()
2120
return query === name
2221
})
23-
24-
const emit = defineEmits<{
25-
focus: [index: number]
26-
}>()
2722
</script>
2823

2924
<template>
3025
<article
31-
class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50"
26+
class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover"
3227
:class="{
33-
'bg-bg-muted border-border-hover': selected,
3428
'border-accent/30 bg-accent/5': isExactMatch,
3529
}"
3630
>
@@ -50,8 +44,6 @@ const emit = defineEmits<{
5044
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
5145
class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
5246
:data-result-index="index"
53-
@focus="index != null && emit('focus', index)"
54-
@mouseenter="index != null && emit('focus', index)"
5547
>{{ result.package.name }}</NuxtLink
5648
>
5749
<span

app/components/PackageList.vue

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ const props = defineProps<{
2929
pageSize?: PageSize
3030
/** Initial page to scroll to (1-indexed) */
3131
initialPage?: number
32-
/** Selected result index (for keyboard navigation) */
33-
selectedIndex?: number
3432
/** Search query for highlighting exact matches */
3533
searchQuery?: string
3634
/** View mode: cards or table */
@@ -48,8 +46,6 @@ const emit = defineEmits<{
4846
'loadMore': []
4947
/** Emitted when the visible page changes */
5048
'pageChange': [page: number]
51-
/** Emitted when a result is hovered/focused */
52-
'select': [index: number]
5349
/** Emitted when sort option changes (table view) */
5450
'update:sortOption': [option: SortOption]
5551
/** Emitted when a keyword is clicked */
@@ -153,9 +149,7 @@ defineExpose({
153149
:results="displayedResults"
154150
:columns="columns"
155151
v-model:sort-option="sortOption"
156-
:selected-index="selectedIndex"
157152
:is-loading="isLoading"
158-
@select="emit('select', $event)"
159153
@click-keyword="emit('clickKeyword', $event)"
160154
/>
161155
</template>
@@ -179,12 +173,10 @@ defineExpose({
179173
:result="item as NpmSearchResult"
180174
:heading-level="headingLevel"
181175
:show-publisher="showPublisher"
182-
:selected="index === (selectedIndex ?? -1)"
183176
:index="index"
184177
:search-query="searchQuery"
185178
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
186179
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
187-
@focus="emit('select', $event)"
188180
/>
189181
</div>
190182
</template>
@@ -199,7 +191,6 @@ defineExpose({
199191
:result="item"
200192
:heading-level="headingLevel"
201193
:show-publisher="showPublisher"
202-
:selected="index === (selectedIndex ?? -1)"
203194
:index="index"
204195
:search-query="searchQuery"
205196
/>
@@ -230,12 +221,10 @@ defineExpose({
230221
:result="item"
231222
:heading-level="headingLevel"
232223
:show-publisher="showPublisher"
233-
:selected="index === (selectedIndex ?? -1)"
234224
:index="index"
235225
:search-query="searchQuery"
236226
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
237227
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
238-
@focus="emit('select', $event)"
239228
/>
240229
</li>
241230
</ol>

app/components/PackagePlaygrounds.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import type { PlaygroundLink } from '#shared/types'
3+
import { decodeHtmlEntities } from '~/utils/formatters'
34
45
const props = defineProps<{
56
links: PlaygroundLink[]
@@ -126,7 +127,7 @@ function focusMenuItem(index: number) {
126127
:class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']"
127128
aria-hidden="true"
128129
/>
129-
<span class="truncate text-fg-muted">{{ firstLink.label }}</span>
130+
<span class="truncate text-fg-muted">{{ decodeHtmlEntities(firstLink.label) }}</span>
130131
</a>
131132
</AppTooltip>
132133

@@ -182,7 +183,7 @@ function focusMenuItem(index: number) {
182183
:class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']"
183184
aria-hidden="true"
184185
/>
185-
<span class="truncate">{{ link.label }}</span>
186+
<span class="truncate">{{ decodeHtmlEntities(link.label) }}</span>
186187
</a>
187188
</AppTooltip>
188189
</div>

app/components/PackageTable.vue

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types
66
const props = defineProps<{
77
results: NpmSearchResult[]
88
columns: ColumnConfig[]
9-
selectedIndex?: number
109
isLoading?: boolean
1110
}>()
1211
1312
const sortOption = defineModel<SortOption>('sortOption')
1413
1514
const emit = defineEmits<{
16-
select: [index: number]
1715
clickKeyword: [keyword: string]
1816
}>()
1917
@@ -318,9 +316,7 @@ function getColumnLabelKey(id: ColumnId): string {
318316
:key="result.package.name"
319317
:result="result"
320318
:columns="columns"
321-
:selected="selectedIndex === index"
322319
:index="index"
323-
@focus="emit('select', index)"
324320
@click-keyword="emit('clickKeyword', $event)"
325321
/>
326322
</template>

0 commit comments

Comments
 (0)