Skip to content

Commit e68484c

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/contributor-list-sort-by-role
2 parents ba9b5c7 + 323bb30 commit e68484c

109 files changed

Lines changed: 6967 additions & 938 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.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ file-tree-sprite.svg
4545

4646
# output
4747
.vercel
48+
.nvmrc

.node-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24

CONTRIBUTING.md

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ This focus helps guide our project decisions as a community and what we choose t
3333
- [Available commands](#available-commands)
3434
- [Project structure](#project-structure)
3535
- [Local connector CLI](#local-connector-cli)
36+
- [Mock connector (for local development)](#mock-connector-for-local-development)
3637
- [Code style](#code-style)
3738
- [TypeScript](#typescript)
3839
- [Server API patterns](#server-api-patterns)
@@ -104,6 +105,10 @@ pnpm dev # Start development server
104105
pnpm build # Production build
105106
pnpm preview # Preview production build
106107

108+
# Connector
109+
pnpm npmx-connector # Start the real connector (requires npm login)
110+
pnpm mock-connector # Start the mock connector (no npm login needed)
111+
107112
# Code Quality
108113
pnpm lint # Run linter (oxlint + oxfmt)
109114
pnpm lint:fix # Auto-fix lint issues
@@ -157,6 +162,36 @@ pnpm npmx-connector
157162

158163
The connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev.
159164

165+
### Mock connector (for local development)
166+
167+
If you're working on admin features (org management, package access controls, operations queue) and don't want to use your real npm account, you can run the mock connector instead:
168+
169+
```bash
170+
pnpm mock-connector
171+
```
172+
173+
This starts a mock connector server pre-populated with sample data (orgs, teams, members, packages). No npm login is required — operations succeed immediately without making real npm CLI calls.
174+
175+
The mock connector prints a connection URL to the terminal, just like the real connector. Click it (or paste the token manually) to connect the UI.
176+
177+
**Options:**
178+
179+
```bash
180+
pnpm mock-connector # default: port 31415, user "mock-user", sample data
181+
pnpm mock-connector --port 9999 # custom port
182+
pnpm mock-connector --user alice # custom username
183+
pnpm mock-connector --empty # start with no pre-populated data
184+
```
185+
186+
**Default sample data:**
187+
188+
- **@nuxt**: 4 members (mock-user, danielroe, pi0, antfu), 3 teams (core, docs, triage)
189+
- **@unjs**: 2 members (mock-user, pi0), 1 team (maintainers)
190+
- **Packages**: @nuxt/kit, @nuxt/schema, @unjs/nitro with team-based access controls
191+
192+
> [!TIP]
193+
> Run `pnpm dev` in a separate terminal to start the Nuxt dev server, then click the connection URL from the mock connector to connect.
194+
160195
## Code style
161196

162197
When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
@@ -381,7 +416,7 @@ npmx.dev uses [@nuxtjs/i18n](https://i18n.nuxtjs.org/) for internationalization.
381416
- All user-facing strings should use translation keys via `$t()` in templates and script
382417
- Translation files live in [`i18n/locales/`](i18n/locales) (e.g., `en-US.json`)
383418
- We use the `no_prefix` strategy (no `/en-US/` or `/fr-FR/` in URLs)
384-
- Locale preference is stored in cookies and respected on subsequent visits
419+
- Locale preference is stored in `localStorage` and respected on subsequent visits
385420

386421
### i18n commands
387422

@@ -752,6 +787,74 @@ You need to either:
752787
1. Add a fixture file for that package/endpoint
753788
2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server)
754789

790+
### Testing connector features
791+
792+
Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server.
793+
794+
#### Architecture
795+
796+
The mock connector infrastructure is shared between the CLI, E2E tests, and Vitest component tests:
797+
798+
```
799+
cli/src/
800+
├── types.ts # ConnectorEndpoints contract (shared by real + mock)
801+
├── mock-state.ts # MockConnectorStateManager (canonical source)
802+
├── mock-app.ts # H3 mock app + MockConnectorServer class
803+
└── mock-server.ts # CLI entry point (pnpm mock-connector)
804+
805+
test/test-utils/ # Re-exports from cli/src/ for test convenience
806+
test/e2e/helpers/ # E2E-specific wrappers (fixtures, global setup)
807+
```
808+
809+
Both the real server (`cli/src/server.ts`) and the mock server (`cli/src/mock-app.ts`) conform to the `ConnectorEndpoints` interface defined in `cli/src/types.ts`. This ensures the API contract is enforced by TypeScript. When adding a new endpoint, update `ConnectorEndpoints` first, then implement it in both servers.
810+
811+
#### Vitest component tests (`test/nuxt/`)
812+
813+
- Mock the `useConnector` composable with reactive state
814+
- Use `document.body` queries for components using Teleport
815+
- See `test/nuxt/components/HeaderConnectorModal.spec.ts` for an example
816+
817+
```typescript
818+
// Create mock state
819+
const mockState = ref({ connected: false, npmUser: null, ... })
820+
821+
// Mock the composable
822+
vi.mock('~/composables/useConnector', () => ({
823+
useConnector: () => ({
824+
isConnected: computed(() => mockState.value.connected),
825+
// ... other properties
826+
}),
827+
}))
828+
```
829+
830+
#### Playwright E2E tests (`test/e2e/`)
831+
832+
- A mock HTTP server starts automatically via Playwright's global setup
833+
- Use the `mockConnector` fixture to set up test data and the `gotoConnected` helper to navigate with authentication
834+
835+
```typescript
836+
test('shows org members', async ({ page, gotoConnected, mockConnector }) => {
837+
// Set up test data
838+
await mockConnector.setOrgData('@testorg', {
839+
users: { testuser: 'owner', member1: 'admin' },
840+
})
841+
842+
// Navigate with connector authentication
843+
await gotoConnected('/@testorg')
844+
845+
// Test assertions
846+
await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible()
847+
})
848+
```
849+
850+
The mock connector supports test endpoints for state manipulation:
851+
852+
- `/__test__/reset` - Reset all mock state
853+
- `/__test__/org` - Set org users, teams, and team members
854+
- `/__test__/user-orgs` - Set user's organizations
855+
- `/__test__/user-packages` - Set user's packages
856+
- `/__test__/package` - Set package collaborators
857+
755858
## Submitting changes
756859

757860
### Before submitting

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ We welcome contributions – please do feel free to explore the project and
155155
- [npm-userscript](https://github.com/bluwy/npm-userscript) – Browser userscript with various improvements and fixes for npmjs.com
156156
- [npm-alt](https://npm.willow.sh/) – An alternative npm package browser
157157
- [npkg.lorypelli.dev](https://npkg.lorypelli.dev/) – An alternative frontend to npm made with as little client-side JavaScript as possible
158-
- [vscode-npmx](https://github.com/npmx-dev/vscode-npmx) – VSCode extension for npmx
158+
- [vscode-npmx](https://github.com/npmx-dev/vscode-npmx) – Official VSCode extension for npmx
159+
- [vscode-open-in-npmx](https://github.com/sybers/vscode-open-in-npmx) – VSCode shortcut to open packages on npmx
159160
- [nxjt](https://nxjt.netlify.app) – npmx Jump To: Quickly navigate to npmx common webpages.
160161
- [npmx-weekly](https://npmx-weekly.trueberryless.org/) – A weekly newsletter for the npmx ecosystem. Add your own content via suggestions in the weekly PR on [GitHub](https://github.com/trueberryless-org/npmx-weekly/pulls?q=is%3Aopen+is%3Apr+label%3A%22%F0%9F%95%94+weekly+post%22).
161162
- [npmx-digest](https://npmx-digest.trueberryless.org/) – An automated news aggregation website that summarizes npmx activity from GitHub and Bluesky every 8 hours.

app/app.vue

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,16 @@ if (import.meta.client) {
121121
<template>
122122
<div class="min-h-screen flex flex-col bg-bg text-fg">
123123
<NuxtPwaAssets />
124-
<LinkBase to="#main-content" variant="button-primary" class="skip-link">{{
125-
$t('common.skip_link')
126-
}}</LinkBase>
124+
<LinkBase
125+
:to="{ hash: '#main-content', query: route.query, params: route.params }"
126+
variant="button-primary"
127+
class="skip-link"
128+
>{{ $t('common.skip_link') }}</LinkBase
129+
>
127130

128131
<AppHeader :show-logo="!isHomepage" />
129132

130-
<div id="main-content" class="flex-1 flex flex-col">
133+
<div id="main-content" class="flex-1 flex flex-col" tabindex="-1">
131134
<NuxtPage />
132135
</div>
133136

app/components/AppHeader.vue

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
124124
125125
const showFullSearch = shallowRef(false)
126126
const showMobileMenu = shallowRef(false)
127+
const { env } = useAppConfig().buildInfo
127128
128129
// On mobile, clicking logo+search button expands search
129130
const route = useRoute()
@@ -180,7 +181,7 @@ onKeyStroke(
180181
for (const link of desktopLinks.value) {
181182
if (link.to && link.keyshortcut && isKeyWithoutModifiers(e, link.keyshortcut)) {
182183
e.preventDefault()
183-
navigateTo(link.to.name)
184+
navigateTo(link.to)
184185
break
185186
}
186187
}
@@ -194,31 +195,34 @@ onKeyStroke(
194195
<div class="absolute inset-0 bg-bg/80 backdrop-blur-md" />
195196
<nav
196197
:aria-label="$t('nav.main_navigation')"
197-
class="relative container min-h-14 flex items-center gap-2 z-1"
198-
:class="isOnHomePage ? 'justify-end' : 'justify-between'"
198+
class="relative container min-h-14 flex items-center gap-2 z-1 justify-end"
199199
>
200-
<!-- Mobile: Logo + search button (expands search, doesn't navigate) -->
201-
<button
200+
<!-- Mobile: Logo (navigates home) -->
201+
<NuxtLink
202202
v-if="!isSearchExpanded && !isOnHomePage"
203-
type="button"
204-
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 rounded"
205-
:aria-label="$t('nav.tap_to_search')"
206-
@click="expandMobileSearch"
203+
to="/"
204+
:aria-label="$t('header.home')"
205+
class="sm:hidden flex-shrink-0 font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring"
207206
>
208207
<AppLogo class="w-8 h-8 rounded-lg" />
209-
<span class="i-carbon:search w-4 h-4 text-fg-subtle" aria-hidden="true" />
210-
</button>
208+
</NuxtLink>
211209

212210
<!-- Desktop: Logo (navigates home) -->
213211
<div v-if="showLogo" class="hidden sm:flex flex-shrink-0 items-center">
214212
<NuxtLink
215213
:to="{ name: 'index' }"
216214
:aria-label="$t('header.home')"
217215
dir="ltr"
218-
class="inline-flex items-center gap-1 header-logo font-mono text-lg font-medium text-fg hover:text-fg/90 transition-colors duration-200 rounded"
216+
class="relative inline-flex items-center gap-1 header-logo font-mono text-lg font-medium text-fg hover:text-fg/90 transition-colors duration-200 rounded"
219217
>
220-
<AppLogo class="w-8 h-8 rounded-lg" />
221-
<span>npmx</span>
218+
<AppLogo class="w-7 h-7 rounded-lg" />
219+
<span class="pb-0.5">npmx</span>
220+
<span
221+
aria-hidden="true"
222+
class="scale-35 transform-origin-br font-mono tracking-wide text-accent absolute bottom-0.5 -inset-ie-1"
223+
>
224+
{{ env === 'release' ? 'alpha' : env }}
225+
</span>
222226
</NuxtLink>
223227
</div>
224228
<!-- Spacer when logo is hidden on desktop -->
@@ -275,6 +279,17 @@ onKeyStroke(
275279
<HeaderAccountMenu />
276280
</div>
277281

282+
<!-- Mobile: Search button (expands search) -->
283+
<ButtonBase
284+
type="button"
285+
class="sm:hidden ms-auto"
286+
:aria-label="$t('nav.tap_to_search')"
287+
:aria-expanded="showMobileMenu"
288+
@click="expandMobileSearch"
289+
v-if="!isSearchExpanded && !isOnHomePage"
290+
classicon="i-carbon:search"
291+
/>
292+
278293
<!-- Mobile: Menu button (always visible, click to open menu) -->
279294
<ButtonBase
280295
type="button"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<script setup lang="ts">
2+
import { BLUESKY_API, BSKY_POST_AT_URI_REGEX } from '#shared/utils/constants'
3+
4+
const props = defineProps<{
5+
/** AT URI of the post, e.g. at://did:plc:.../app.bsky.feed.post/... */
6+
uri: string
7+
}>()
8+
9+
interface PostAuthor {
10+
did: string
11+
handle: string
12+
displayName?: string
13+
avatar?: string
14+
}
15+
16+
interface EmbedImage {
17+
thumb: string
18+
fullsize: string
19+
alt: string
20+
aspectRatio?: { width: number; height: number }
21+
}
22+
23+
interface BlueskyPost {
24+
uri: string
25+
author: PostAuthor
26+
record: { text: string; createdAt: string }
27+
embed?: { $type: string; images?: EmbedImage[] }
28+
likeCount?: number
29+
replyCount?: number
30+
repostCount?: number
31+
}
32+
33+
const postUrl = computed(() => {
34+
const match = props.uri.match(BSKY_POST_AT_URI_REGEX)
35+
if (!match) return null
36+
const [, did, rkey] = match
37+
return `https://bsky.app/profile/${did}/post/${rkey}`
38+
})
39+
40+
const { data: post, status } = useAsyncData(
41+
`bsky-post-${props.uri}`,
42+
async (): Promise<BlueskyPost | null> => {
43+
const response = await $fetch<{ posts: BlueskyPost[] }>(
44+
`${BLUESKY_API}/xrpc/app.bsky.feed.getPosts`,
45+
{ query: { uris: props.uri } },
46+
)
47+
return response.posts[0] ?? null
48+
},
49+
{ lazy: true, server: false },
50+
)
51+
</script>
52+
53+
<template>
54+
<div
55+
v-if="status === 'pending'"
56+
class="rounded-lg border border-border bg-bg-subtle p-6 text-center text-fg-subtle text-sm"
57+
>
58+
<span class="i-svg-spinners:90-ring-with-bg h-5 w-5 inline-block" />
59+
</div>
60+
61+
<a
62+
v-else-if="post"
63+
:href="postUrl ?? '#'"
64+
target="_blank"
65+
rel="noopener noreferrer"
66+
class="block rounded-lg border border-border bg-bg-subtle p-4 sm:p-5 no-underline hover:border-border-hover transition-colors duration-200"
67+
>
68+
<!-- Author row -->
69+
<div class="flex items-center gap-3 mb-3">
70+
<img
71+
v-if="post.author.avatar"
72+
:src="`${post.author.avatar}?size=48`"
73+
:alt="post.author.displayName || post.author.handle"
74+
width="40"
75+
height="40"
76+
class="w-10 h-10 rounded-full"
77+
loading="lazy"
78+
/>
79+
<div class="min-w-0">
80+
<div class="font-medium text-fg truncate">
81+
{{ post.author.displayName || post.author.handle }}
82+
</div>
83+
<div class="text-sm text-fg-subtle truncate">@{{ post.author.handle }}</div>
84+
</div>
85+
<span
86+
class="i-carbon:logo-bluesky w-5 h-5 text-fg-subtle ms-auto shrink-0"
87+
aria-hidden="true"
88+
/>
89+
</div>
90+
91+
<!-- Post text -->
92+
<p class="text-fg-muted whitespace-pre-wrap leading-relaxed mb-3">{{ post.record.text }}</p>
93+
94+
<!-- Embedded images -->
95+
<template v-if="post.embed?.images?.length">
96+
<img
97+
v-for="(img, i) in post.embed.images"
98+
:key="i"
99+
:src="img.fullsize"
100+
:alt="img.alt"
101+
class="w-full mb-3 rounded-lg object-cover"
102+
:style="
103+
img.aspectRatio
104+
? { aspectRatio: `${img.aspectRatio.width}/${img.aspectRatio.height}` }
105+
: undefined
106+
"
107+
loading="lazy"
108+
/>
109+
</template>
110+
111+
<!-- Timestamp + engagement -->
112+
<div class="flex items-center gap-4 text-sm text-fg-subtle">
113+
<DateTime :datetime="post.record.createdAt" date-style="medium" />
114+
<span v-if="post.likeCount" class="flex items-center gap-1">
115+
<span class="i-carbon:favorite w-3.5 h-3.5" aria-hidden="true" />
116+
{{ post.likeCount }}
117+
</span>
118+
<span v-if="post.repostCount" class="flex items-center gap-1">
119+
<span class="i-carbon:repeat w-3.5 h-3.5" aria-hidden="true" />
120+
{{ post.repostCount }}
121+
</span>
122+
<span v-if="post.replyCount" class="flex items-center gap-1">
123+
<span class="i-carbon:chat w-3.5 h-3.5" aria-hidden="true" />
124+
{{ post.replyCount }}
125+
</span>
126+
</div>
127+
</a>
128+
</template>

0 commit comments

Comments
 (0)