Skip to content

feat: PDS landing page#1750

Open
Kai-ros wants to merge 4 commits intonpmx-dev:mainfrom
Kai-ros:feat/pds-landing-page
Open

feat: PDS landing page#1750
Kai-ros wants to merge 4 commits intonpmx-dev:mainfrom
Kai-ros:feat/pds-landing-page

Conversation

@Kai-ros
Copy link
Contributor

@Kai-ros Kai-ros commented Feb 28, 2026

🔗 Linked issue

resolves #1646

🧭 Context

This PR inherits the original work from #1742 by @pauliecodes. It will also include some minor cleanups and additions.

Work Completed Prior to Handover

  • Server Redirect to /pds
  • Dedicated PDS Page
  • Info about the page's purpose
  • location of datacenter
  • PDS moover migration link
  • display akin to github contributors
  • OG image for link sharing

📚 Description

State of the page at handoff:

image

@vercel
Copy link

vercel bot commented Feb 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 1, 2026 0:19am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 1, 2026 0:19am
npmx-lunaria Ignored Ignored Mar 1, 2026 0:19am

Request Review

@codecov
Copy link

codecov bot commented Feb 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 28, 2026

📝 Walkthrough

Walkthrough

Adds a new PDS landing page at /pds (app/pages/pds.vue) with SEO/OpenGraph setup and a client-side avatar gallery. Introduces server APIs to fetch PDS user profiles (cached, batched requests) and to build a graph of PDS repos and follows, plus a lightweight AtprotoProfile type and new constants for XRPC endpoints. Registers /pds for static prerendering and includes it in canonical redirects. Server handlers batch actor requests in groups of 25 and perform resilient error handling/logging.

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly relates to implementing a PDS landing page at /pds with all specified features from issue #1646.
Linked Issues check ✅ Passed All primary objectives from issue #1646 are implemented: PDS page at /pds, server redirect, informational content about npmx.social, Nuremberg Germany location, PDS MOOver migration link, GitHub-style avatar gallery, and OG image setup.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the PDS landing page feature. No unrelated or out-of-scope modifications detected in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
app/pages/pds.vue (1)

48-48: Drop inline button focus-visible utility and rely on global focus styling.

Line 48 adds focus-visible:outline-accent/70 on a <button>, but this project already standardises button focus-visible styles globally in app/assets/main.css.

Suggested patch
-            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"

Based on learnings: "focus-visible styling for button and select elements is implemented globally in app/assets/main.css ... Do not apply per-element inline utility classes ... Rely on the global rule for consistency and maintainability."


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5ccbff and 24196d6.

📒 Files selected for processing (5)
  • app/pages/pds.vue
  • nuxt.config.ts
  • server/api/atproto/pds-graphs.get.ts
  • server/api/atproto/pds-users.get.ts
  • server/middleware/canonical-redirects.global.ts

Comment on lines +53 to +70
for (const did of dids) {
const followResponse = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}`,
)

if (!followResponse.ok) {
console.warn(`Failed to fetch atproto profiles: ${followResponse.status}`)
continue
}

const followData = await followResponse.json()

for (const followedUser of followData.follows) {
if (localDids.has(followedUser.did)) {
links.push({ source: did, target: followedUser.did })
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'pds-graphs.get.ts' | head -n1)"
if [ -z "$file" ]; then
  echo "File not found"
  exit 1
fi

echo "=== File: $file ==="
wc -l "$file"

echo
echo "=== Lines 45-110 (context around 53-70) ==="
nl -ba "$file" | sed -n '45,110p'

echo
echo "=== Loop/fetch/error-handling markers ==="
rg -n "for \(const did|await fetch|try \{|catch \(" "$file"

echo
echo "=== Function signature and outer context (lines 1-52) ==="
nl -ba "$file" | sed -n '1,52p'

Repository: npmx-dev/npmx.dev

Length of output: 257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'pds-graphs.get.ts' | head -n1)"
if [ -z "$file" ]; then
  echo "File not found"
  exit 1
fi

echo "=== File: $file ==="
wc -l "$file"

echo
echo "=== Lines 45-110 (context around 53-70) ==="
cat -n "$file" | sed -n '45,110p'

echo
echo "=== Loop/fetch/error-handling markers ==="
rg -n "for \(const did|await fetch|try \{|catch \(" "$file"

echo
echo "=== Full file content ==="
cat -n "$file"

Repository: npmx-dev/npmx.dev

Length of output: 4361


Follow-edge collection lacks consistent error handling and is vulnerable to runtime failures.

Lines 53-70 violate the coding guideline "Use error handling patterns consistently". Unlike the profiles batch loop above (lines 35-50, which wraps fetch/json in try/catch), the follows loop has no error handling wrapper. The await followResponse.json() call on line 63 can throw unhandled errors if the response body is invalid, crashing the entire endpoint. Additionally, line 65 accesses followData.follows without verifying it exists, violating strict type-safety requirements. The sequential per-DID approach also creates unnecessary latency compared to the batched pattern used for profiles.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
server/api/atproto/pds-users.get.ts (1)

13-13: ⚠️ Potential issue | 🟠 Major

Add timeouts to outbound fetches.

Line 13 and Line 35 perform blocking network calls without an abort signal; slow upstream responses can tie up server capacity.

🩹 Suggested patch
+const REQUEST_TIMEOUT_MS = 10_000
+
 export default defineCachedEventHandler(
   async (): Promise<AtprotoProfile[]> => {
@@
-    const response = await fetch(ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC)
+    const response = await fetch(ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC, {
+      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+    })
@@
-        fetch(url.toString())
+        fetch(url.toString(), {
+          signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+        })
#!/bin/bash
set -euo pipefail
file="server/api/atproto/pds-users.get.ts"
echo "Inspecting fetch callsites and timeout usage in $file"
rg -n '\bfetch\s*\(' "$file"
rg -n 'AbortSignal\.timeout|signal\s*:' "$file" || true

Also applies to: 35-35

app/pages/pds.vue (1)

2-2: ⚠️ Potential issue | 🔴 Critical

Fix the type import source to resolve the type-check failure.

Line 2 imports a named export that is not provided by the server route module.

🩹 Suggested patch
-import type { AtprotoProfile } from '#server/api/atproto/pds-users.get.ts'
+import type { AtprotoProfile } from '#shared/types/atproto'
#!/bin/bash
set -euo pipefail
echo "Verify AtprotoProfile export source and current import usage"
rg -n 'export type AtprotoProfile' shared/types/atproto.ts
rg -n 'import type \{ AtprotoProfile \}' app/pages/pds.vue
rg -n 'AtprotoProfile' server/api/atproto/pds-users.get.ts
🧹 Nitpick comments (3)
shared/utils/constants.ts (1)

19-20: Derive the AppView URL from BLUESKY_API to avoid origin drift.

Small DRY improvement: keep a single source of truth for the base URL.

♻️ Suggested patch
 export const BSKY_APP_VIEW_USER_PROFILES_XRPC =
-  'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles'
+  `${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles`
server/api/atproto/pds-users.get.ts (1)

22-23: Avoid unchecked JSON casts for untrusted payloads.

The direct cast can crash at runtime if repos is missing or malformed. Prefer guarded parsing before mapping DIDs.

🛡️ Suggested patch
-    const listRepos = (await response.json()) as { repos: { did: string }[] }
-    const dids = listRepos.repos.map(repo => repo.did)
+    const payload: unknown = await response.json()
+    const repos = Array.isArray((payload as { repos?: unknown }).repos)
+      ? (payload as { repos: Array<{ did?: unknown }> }).repos
+      : []
+    const dids = repos.flatMap(repo => (typeof repo.did === 'string' ? [repo.did] : []))

As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".

app/pages/pds.vue (1)

48-48: Remove per-element focus-visible utility from the button class.

This should rely on the global button:focus-visible rule for consistency across the app.

♻️ Suggested patch
-            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"

Based on learnings: "focus-visible styling for button and select elements is implemented globally in app/assets/main.css ... Do not apply per-element inline utility classes".


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 24196d6 and 790f6c8.

📒 Files selected for processing (4)
  • app/pages/pds.vue
  • server/api/atproto/pds-users.get.ts
  • shared/types/atproto.ts
  • shared/utils/constants.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
app/pages/pds.vue (2)

46-54: Remove inline focus-visible utility and add aria-label for accessibility.

Two issues with this button:

  1. The focus-visible:outline-accent/70 class should be removed—focus-visible styling for buttons is handled globally via main.css.

  2. On smaller screens, the text is hidden (hidden sm:inline) leaving only the icon visible. Without an aria-label, screen reader users have no context for what this button does.

♻️ Suggested fix
           <button
             type="button"
-            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+            class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
             `@click`="router.back()"
             v-if="canGoBack"
+            aria-label="Go back"
           >

Based on learnings: "focus-visible styling for buttons and selects is applied globally via main.css… individual buttons or selects in Vue components should not rely on inline focus-visible utility classes."


129-160: Consider adding a fallback for an empty community list.

The current logic handles pending and error states well, but if the fetch succeeds and usersWithAvatars is empty (e.g., no users have avatars or all images fail to load), nothing is rendered. A brief message would improve the UX.

♻️ Suggested addition after the `ul` block
           </ul>
+          <p v-else class="text-fg-subtle text-sm">
+            No community members to display yet.
+          </p>
         </div>

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 790f6c8 and ebc78de.

📒 Files selected for processing (1)
  • app/pages/pds.vue

Copy link
Contributor

@fatfingers23 fatfingers23 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Added a few comments, mostly just using atproto/lex client in more places so we have the types/validation already and are not relying on fetch and creating our own types

@@ -0,0 +1,76 @@
import type { AtprotoProfile } from '~~/server/api/atproto/pds-users.get'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be #shared

export default defineCachedEventHandler(
async (): Promise<AtprotoProfile[]> => {
// INFO: Request npmx.social PDS for every hosted user account
const response = await fetch(ONE_THOUSAND_NPMX_USER_ACCOUNTS_XRPC)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not hurt to go ahead and do this in a loop too for when we get over 1k users. Can also use the Client in atproto/lex for these XRPC calls. This client has all the types and validation baked in

https://npmx.dev/package/@atproto/lex#user-content-making-simple-xrpc-requests


for (let i = 0; i < dids.length; i += USER_BATCH_AMOUNT) {
const batch = dids.slice(i, i + USER_BATCH_AMOUNT)
const url = new URL(BSKY_APP_VIEW_USER_PROFILES_XRPC)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can use a client as well

<h2 id="community-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
Who is here
</h2>
<p class="text-fg-muted leading-relaxed mb-6">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be nice to add a count like "Home to 192 accounts" or something and the count is only active accounts

Failed to load PDS community.
</div>
<ul
v-else-if="usersWithAvatars.length"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May also be fun to do something here where if they do not have an avatar to show just the npmx logo and a count of how many that is beside it to show how many are new to the atmosphere accounts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: pds Info/landing page

3 participants