[READ-ONLY] a fast, modern browser for the npm registry

feat: add `/recharging` page (#1363)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Joaquín Sánchez
Daniel Roe
and committed by
GitHub
e5f60f6f fd3a597d

+737
+128
app/components/BlueskyPostEmbed.client.vue
··· 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>
+304
app/pages/recharging.vue
··· 1 + <script setup lang="ts"> 2 + definePageMeta({ 3 + name: 'vacations', 4 + }) 5 + 6 + useSeoMeta({ 7 + title: () => `${$t('vacations.title')} - npmx`, 8 + description: () => $t('vacations.meta_description'), 9 + ogTitle: () => `${$t('vacations.title')} - npmx`, 10 + ogDescription: () => $t('vacations.meta_description'), 11 + twitterTitle: () => `${$t('vacations.title')} - npmx`, 12 + twitterDescription: () => $t('vacations.meta_description'), 13 + }) 14 + 15 + defineOgImageComponent('Default', { 16 + title: () => $t('vacations.title'), 17 + description: () => $t('vacations.meta_description'), 18 + }) 19 + 20 + const router = useRouter() 21 + const canGoBack = useCanGoBack() 22 + 23 + const { data: stats } = useFetch('/api/repo-stats') 24 + 25 + /** 26 + * Formats a number into a compact human-readable string. 27 + * e.g. 1142 → "1.1k+", 163 → "160+" 28 + */ 29 + function formatStat(n: number): string { 30 + if (n >= 1000) { 31 + const k = Math.floor(n / 100) / 10 32 + return `${k}k+` 33 + } 34 + return `${Math.floor(n / 10) * 10}+` 35 + } 36 + 37 + // --- Cosy fireplace easter egg --- 38 + const logClicks = ref(0) 39 + const fireVisible = ref(false) 40 + function pokeLog() { 41 + logClicks.value++ 42 + if (logClicks.value >= 3) { 43 + fireVisible.value = true 44 + } 45 + } 46 + 47 + // Icons that tile across the banner, repeating to fill. 48 + // Classes must be written out statically so UnoCSS can detect them at build time. 49 + const icons = [ 50 + 'i-carbon:snowflake', 51 + 'i-carbon:mountain', 52 + 'i-carbon:tree', 53 + 'i-carbon:cafe', 54 + 'i-carbon:book', 55 + 'i-carbon:music', 56 + 'i-carbon:snowflake', 57 + 'i-carbon:star', 58 + 'i-carbon:moon', 59 + ] as const 60 + 61 + // --- .ics calendar reminder --- 62 + 63 + // Format as UTC for the .ics file 64 + const fmt = (d: Date) => 65 + d 66 + .toISOString() 67 + .replace(/[-:]/g, '') 68 + .replace(/\.\d{3}/, '') 69 + 70 + // Pick a random daytime hour (9–17) in the user's local timezone on Feb 22 71 + // so reminders are staggered and people don't all flood in at once. 72 + function downloadIcs() { 73 + const hour = 9 + Math.floor(Math.random() * 9) // 9..17 74 + const start = new Date(2026, 1, 22, hour, 0, 0) // month is 0-indexed 75 + const end = new Date(2026, 1, 22, hour + 1, 0, 0) 76 + 77 + const uid = `npmx-vacations-${start.getTime()}@npmx.dev` 78 + 79 + const ics = [ 80 + 'BEGIN:VCALENDAR', 81 + 'VERSION:2.0', 82 + 'PRODID:-//npmx//recharging//EN', 83 + 'BEGIN:VEVENT', 84 + `DTSTART:${fmt(start)}`, 85 + `DTEND:${fmt(end)}`, 86 + `SUMMARY:npmx Discord is back!`, 87 + `DESCRIPTION:The npmx team is back from vacation. Time to rejoin! https://chat.npmx.dev`, 88 + 'STATUS:CONFIRMED', 89 + `UID:${uid}`, 90 + 'END:VEVENT', 91 + 'END:VCALENDAR', 92 + ].join('\r\n') 93 + 94 + const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }) 95 + const url = URL.createObjectURL(blob) 96 + const a = document.createElement('a') 97 + a.href = url 98 + a.download = 'npmx-discord-reminder.ics' 99 + a.click() 100 + URL.revokeObjectURL(url) 101 + } 102 + </script> 103 + 104 + <template> 105 + <main class="container flex-1 py-12 sm:py-16 overflow-x-hidden max-w-full"> 106 + <article class="max-w-2xl mx-auto"> 107 + <header class="mb-12"> 108 + <div class="max-w-2xl mx-auto py-8 bg-none flex justify-center"> 109 + <!-- Icon / Illustration --> 110 + <div class="relative inline-block"> 111 + <div class="absolute inset-0 bg-accent/20 blur-3xl rounded-full" aria-hidden="true" /> 112 + <span class="relative text-8xl sm:text-9xl animate-bounce-slow inline-block">🏖️</span> 113 + </div> 114 + </div> 115 + <div class="flex items-baseline justify-between gap-4 mb-4"> 116 + <h1 class="font-mono text-3xl sm:text-4xl font-medium"> 117 + {{ $t('vacations.heading') }} 118 + </h1> 119 + <button 120 + type="button" 121 + class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0" 122 + @click="router.back()" 123 + v-if="canGoBack" 124 + > 125 + <span class="i-carbon:arrow-left rtl-flip w-4 h-4" aria-hidden="true" /> 126 + <span class="sr-only sm:not-sr-only">{{ $t('nav.back') }}</span> 127 + </button> 128 + </div> 129 + <i18n-t 130 + keypath="vacations.subtitle" 131 + tag="p" 132 + scope="global" 133 + class="text-fg-muted text-lg sm:text-xl" 134 + > 135 + <template #some> 136 + <span class="line-through decoration-fg">{{ 137 + $t('vacations.stats.subtitle.some') 138 + }}</span> 139 + {{ ' ' }} 140 + <strong class="text-fg">{{ $t('vacations.stats.subtitle.all') }}</strong> 141 + </template> 142 + </i18n-t> 143 + </header> 144 + <!-- Bluesky post embed --> 145 + <div class="my-8"> 146 + <BlueskyPostEmbed 147 + uri="at://did:plc:u5zp7npt5kpueado77kuihyz/app.bsky.feed.post/3mejzn5mrcc2g" 148 + /> 149 + </div> 150 + 151 + <section class="prose prose-invert max-w-none space-y-8"> 152 + <!-- What's happening --> 153 + <div> 154 + <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> 155 + {{ $t('vacations.what.title') }} 156 + </h2> 157 + <p class="text-fg-muted leading-relaxed mb-4"> 158 + <i18n-t keypath="vacations.what.p1" tag="span" scope="global"> 159 + <template #dates> 160 + <strong class="text-fg">{{ $t('vacations.what.dates') }}</strong> 161 + </template> 162 + </i18n-t> 163 + </p> 164 + <p class="text-fg-muted leading-relaxed mb-4"> 165 + <i18n-t keypath="vacations.what.p2" tag="span" scope="global"> 166 + <template #garden> 167 + <code class="font-mono text-fg text-sm">{{ $t('vacations.what.garden') }}</code> 168 + </template> 169 + </i18n-t> 170 + </p> 171 + </div> 172 + 173 + <!-- In the meantime --> 174 + <div> 175 + <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> 176 + {{ $t('vacations.meantime.title') }} 177 + </h2> 178 + <p class="text-fg-muted leading-relaxed"> 179 + <i18n-t keypath="vacations.meantime.p1" tag="span" scope="global"> 180 + <template #site> 181 + <LinkBase class="font-sans" to="/">npmx.dev</LinkBase> 182 + </template> 183 + <template #repo> 184 + <LinkBase class="font-sans" to="https://repo.npmx.dev"> 185 + {{ $t('vacations.meantime.repo_link') }} 186 + </LinkBase> 187 + </template> 188 + </i18n-t> 189 + </p> 190 + </div> 191 + 192 + <!-- Icon banner — a single row of cosy icons, clipped to fill width --> 193 + <div 194 + class="relative mb-12 px-4 border border-border rounded-lg bg-bg-subtle overflow-hidden select-none" 195 + :aria-label="$t('vacations.illustration_alt')" 196 + role="group" 197 + > 198 + <div class="flex items-center gap-4 sm:gap-5 py-3 sm:py-4 w-max"> 199 + <template v-for="n in 4" :key="`set-${n}`"> 200 + <!-- Campsite icon — click it 3x to light the fire --> 201 + <button 202 + type="button" 203 + class="relative shrink-0 cursor-pointer rounded transition-transform duration-200 hover:scale-110 focus-visible:outline-accent/70 w-5 h-5 sm:w-6 sm:h-6" 204 + :aria-label="$t('vacations.poke_log')" 205 + @click="pokeLog" 206 + > 207 + <span 208 + class="absolute inset-0 i-carbon:fire w-5 h-5 sm:w-6 sm:h-6 text-orange-400 transition-opacity duration-400" 209 + :class="fireVisible ? 'opacity-100' : 'opacity-0'" 210 + /> 211 + <span 212 + class="absolute inset-0 i-carbon:campsite w-5 h-5 sm:w-6 sm:h-6 transition-colors duration-400" 213 + :class="fireVisible ? 'text-amber-700' : ''" 214 + /> 215 + </button> 216 + <span 217 + v-for="(icon, i) in icons" 218 + :key="`${n}-${i}`" 219 + class="shrink-0 w-5 h-5 sm:w-6 sm:h-6 opacity-40" 220 + :class="icon" 221 + aria-hidden="true" 222 + /> 223 + </template> 224 + </div> 225 + </div> 226 + 227 + <!-- See you soon --> 228 + <div> 229 + <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> 230 + {{ $t('vacations.return.title') }} 231 + </h2> 232 + <p class="text-fg-muted leading-relaxed mb-6"> 233 + <i18n-t keypath="vacations.return.p1" tag="span" scope="global"> 234 + <template #social> 235 + <LinkBase class="font-sans" to="https://social.npmx.dev"> 236 + {{ $t('vacations.return.social_link') }} 237 + </LinkBase> 238 + </template> 239 + </i18n-t> 240 + </p> 241 + 242 + <!-- Add to calendar button --> 243 + <ButtonBase classicon="i-carbon:calendar" @click="downloadIcs"> 244 + {{ $t('vacations.return.add_to_calendar') }} 245 + </ButtonBase> 246 + </div> 247 + 248 + <div 249 + v-if="stats" 250 + class="grid grid-cols-3 justify-center gap-4 sm:gap-8 mb-8 py-8 border-y border-border/50" 251 + > 252 + <div class="space-y-1 text-center"> 253 + <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> 254 + {{ formatStat(stats.contributors) }} 255 + </div> 256 + <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> 257 + {{ $t('vacations.stats.contributors') }} 258 + </div> 259 + </div> 260 + <div class="space-y-1 text-center"> 261 + <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> 262 + {{ formatStat(stats.commits) }} 263 + </div> 264 + <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> 265 + {{ $t('vacations.stats.commits') }} 266 + </div> 267 + </div> 268 + <div class="space-y-1 text-center"> 269 + <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> 270 + {{ formatStat(stats.pullRequests) }} 271 + </div> 272 + <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> 273 + {{ $t('vacations.stats.pr') }} 274 + </div> 275 + </div> 276 + </div> 277 + </section> 278 + </article> 279 + </main> 280 + </template> 281 + 282 + <style scoped> 283 + .animate-bounce-slow { 284 + animation: bounce 3s infinite; 285 + } 286 + 287 + @media (prefers-reduced-motion: reduce) { 288 + .animate-bounce-slow { 289 + animation: none; 290 + } 291 + } 292 + 293 + @keyframes bounce { 294 + 0%, 295 + 100% { 296 + transform: translateY(-5%); 297 + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); 298 + } 299 + 50% { 300 + transform: translateY(0); 301 + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); 302 + } 303 + } 304 + </style>
+35
i18n/locales/en.json
··· 1110 1110 "p1": "If you encounter an accessibility barrier on {app}, please let us know by opening an issue on our {link}. We take these reports seriously and will do our best to address them.", 1111 1111 "link": "GitHub repository" 1112 1112 } 1113 + }, 1114 + "vacations": { 1115 + "title": "on vacation", 1116 + "meta_description": "The npmx team is recharging. Discord reopens in a week.", 1117 + "heading": "recharging", 1118 + "subtitle": "we've been building npmx at a pace that has cost {some} of us sleep. we don't want that to be the norm! so we are taking a week off. together.", 1119 + "illustration_alt": "a single row of cosy icons", 1120 + "poke_log": "Poke the campfire", 1121 + "what": { 1122 + "title": "what's happening", 1123 + "p1": "discord is closed {dates}.", 1124 + "dates": "February 14 – 21", 1125 + "p2": "all invite links are gone and channels are locked – except {garden}, which stays open for folks who want to keep hanging out.", 1126 + "garden": "#garden" 1127 + }, 1128 + "meantime": { 1129 + "title": "in the meantime", 1130 + "p1": "{site} and {repo} stay open – dig in, file issues, open PRs. we'll get to everything when we're back. just don't expect a fast review. we'll be somewhere near a cosy fireplace.", 1131 + "repo_link": "the repo" 1132 + }, 1133 + "return": { 1134 + "title": "see you soon", 1135 + "p1": "we'll come back recharged and ready for the final push to March 3rd. {social} for updates.", 1136 + "social_link": "follow us on Bluesky", 1137 + "add_to_calendar": "remind me when Discord reopens" 1138 + }, 1139 + "stats": { 1140 + "contributors": "Contributors", 1141 + "commits": "Commits", 1142 + "pr": "PRs Merged", 1143 + "subtitle": { 1144 + "some": "some", 1145 + "all": "all" 1146 + } 1147 + } 1113 1148 } 1114 1149 }
+105
i18n/schema.json
··· 3337 3337 }, 3338 3338 "additionalProperties": false 3339 3339 }, 3340 + "vacations": { 3341 + "type": "object", 3342 + "properties": { 3343 + "title": { 3344 + "type": "string" 3345 + }, 3346 + "meta_description": { 3347 + "type": "string" 3348 + }, 3349 + "heading": { 3350 + "type": "string" 3351 + }, 3352 + "subtitle": { 3353 + "type": "string" 3354 + }, 3355 + "illustration_alt": { 3356 + "type": "string" 3357 + }, 3358 + "poke_log": { 3359 + "type": "string" 3360 + }, 3361 + "what": { 3362 + "type": "object", 3363 + "properties": { 3364 + "title": { 3365 + "type": "string" 3366 + }, 3367 + "p1": { 3368 + "type": "string" 3369 + }, 3370 + "dates": { 3371 + "type": "string" 3372 + }, 3373 + "p2": { 3374 + "type": "string" 3375 + }, 3376 + "garden": { 3377 + "type": "string" 3378 + } 3379 + }, 3380 + "additionalProperties": false 3381 + }, 3382 + "meantime": { 3383 + "type": "object", 3384 + "properties": { 3385 + "title": { 3386 + "type": "string" 3387 + }, 3388 + "p1": { 3389 + "type": "string" 3390 + }, 3391 + "repo_link": { 3392 + "type": "string" 3393 + } 3394 + }, 3395 + "additionalProperties": false 3396 + }, 3397 + "return": { 3398 + "type": "object", 3399 + "properties": { 3400 + "title": { 3401 + "type": "string" 3402 + }, 3403 + "p1": { 3404 + "type": "string" 3405 + }, 3406 + "social_link": { 3407 + "type": "string" 3408 + }, 3409 + "add_to_calendar": { 3410 + "type": "string" 3411 + } 3412 + }, 3413 + "additionalProperties": false 3414 + }, 3415 + "stats": { 3416 + "type": "object", 3417 + "properties": { 3418 + "contributors": { 3419 + "type": "string" 3420 + }, 3421 + "commits": { 3422 + "type": "string" 3423 + }, 3424 + "pr": { 3425 + "type": "string" 3426 + }, 3427 + "subtitle": { 3428 + "type": "object", 3429 + "properties": { 3430 + "some": { 3431 + "type": "string" 3432 + }, 3433 + "all": { 3434 + "type": "string" 3435 + } 3436 + }, 3437 + "additionalProperties": false 3438 + } 3439 + }, 3440 + "additionalProperties": false 3441 + } 3442 + }, 3443 + "additionalProperties": false 3444 + }, 3340 3445 "$schema": { 3341 3446 "type": "string" 3342 3447 }
+35
lunaria/files/en-GB.json
··· 1109 1109 "p1": "If you encounter an accessibility barrier on {app}, please let us know by opening an issue on our {link}. We take these reports seriously and will do our best to address them.", 1110 1110 "link": "GitHub repository" 1111 1111 } 1112 + }, 1113 + "vacations": { 1114 + "title": "on vacation", 1115 + "meta_description": "The npmx team is recharging. Discord reopens in a week.", 1116 + "heading": "recharging", 1117 + "subtitle": "we've been building npmx at a pace that has cost {some} of us sleep. we don't want that to be the norm! so we are taking a week off. together.", 1118 + "illustration_alt": "a single row of cosy icons", 1119 + "poke_log": "Poke the campfire", 1120 + "what": { 1121 + "title": "what's happening", 1122 + "p1": "discord is closed {dates}.", 1123 + "dates": "February 14 – 21", 1124 + "p2": "all invite links are gone and channels are locked – except {garden}, which stays open for folks who want to keep hanging out.", 1125 + "garden": "#garden" 1126 + }, 1127 + "meantime": { 1128 + "title": "in the meantime", 1129 + "p1": "{site} and {repo} stay open – dig in, file issues, open PRs. we'll get to everything when we're back. just don't expect a fast review. we'll be somewhere near a cosy fireplace.", 1130 + "repo_link": "the repo" 1131 + }, 1132 + "return": { 1133 + "title": "see you soon", 1134 + "p1": "we'll come back recharged and ready for the final push to March 3rd. {social} for updates.", 1135 + "social_link": "follow us on Bluesky", 1136 + "add_to_calendar": "remind me when Discord reopens" 1137 + }, 1138 + "stats": { 1139 + "contributors": "Contributors", 1140 + "commits": "Commits", 1141 + "pr": "PRs Merged", 1142 + "subtitle": { 1143 + "some": "some", 1144 + "all": "all" 1145 + } 1146 + } 1112 1147 } 1113 1148 }
+35
lunaria/files/en-US.json
··· 1109 1109 "p1": "If you encounter an accessibility barrier on {app}, please let us know by opening an issue on our {link}. We take these reports seriously and will do our best to address them.", 1110 1110 "link": "GitHub repository" 1111 1111 } 1112 + }, 1113 + "vacations": { 1114 + "title": "on vacation", 1115 + "meta_description": "The npmx team is recharging. Discord reopens in a week.", 1116 + "heading": "recharging", 1117 + "subtitle": "we've been building npmx at a pace that has cost {some} of us sleep. we don't want that to be the norm! so we are taking a week off. together.", 1118 + "illustration_alt": "a single row of cosy icons", 1119 + "poke_log": "Poke the campfire", 1120 + "what": { 1121 + "title": "what's happening", 1122 + "p1": "discord is closed {dates}.", 1123 + "dates": "February 14 – 21", 1124 + "p2": "all invite links are gone and channels are locked – except {garden}, which stays open for folks who want to keep hanging out.", 1125 + "garden": "#garden" 1126 + }, 1127 + "meantime": { 1128 + "title": "in the meantime", 1129 + "p1": "{site} and {repo} stay open – dig in, file issues, open PRs. we'll get to everything when we're back. just don't expect a fast review. we'll be somewhere near a cosy fireplace.", 1130 + "repo_link": "the repo" 1131 + }, 1132 + "return": { 1133 + "title": "see you soon", 1134 + "p1": "we'll come back recharged and ready for the final push to March 3rd. {social} for updates.", 1135 + "social_link": "follow us on Bluesky", 1136 + "add_to_calendar": "remind me when Discord reopens" 1137 + }, 1138 + "stats": { 1139 + "contributors": "Contributors", 1140 + "commits": "Commits", 1141 + "pr": "PRs Merged", 1142 + "subtitle": { 1143 + "some": "some", 1144 + "all": "all" 1145 + } 1146 + } 1112 1147 } 1113 1148 }
+1
nuxt.config.ts
··· 149 149 '/privacy': { prerender: true }, 150 150 '/search': { isr: false, cache: false }, // never cache 151 151 '/settings': { prerender: true }, 152 + '/recharging': { prerender: true }, 152 153 // proxy for insights 153 154 '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' }, 154 155 '/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
+80
server/api/repo-stats.get.ts
··· 1 + import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 2 + 3 + const REPO = 'npmx-dev/npmx.dev' 4 + const GITHUB_HEADERS = { 5 + 'Accept': 'application/vnd.github.v3+json', 6 + 'User-Agent': 'npmx', 7 + } as const 8 + 9 + interface GitHubSearchResponse { 10 + total_count: number 11 + } 12 + 13 + export interface RepoStats { 14 + contributors: number 15 + commits: number 16 + pullRequests: number 17 + } 18 + 19 + export default defineCachedEventHandler( 20 + async (): Promise<RepoStats> => { 21 + const [contributorsCount, commitsCount, prsCount] = await Promise.all([ 22 + fetchPageCount(`https://api.github.com/repos/${REPO}/contributors?per_page=1&anon=false`), 23 + fetchPageCount(`https://api.github.com/repos/${REPO}/commits?per_page=1`), 24 + fetchSearchCount('issues', `repo:${REPO} is:pr is:merged`), 25 + ]) 26 + 27 + return { 28 + contributors: contributorsCount, 29 + commits: commitsCount, 30 + pullRequests: prsCount, 31 + } 32 + }, 33 + { 34 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 35 + swr: true, 36 + name: 'repo-stats', 37 + getKey: () => 'repo-stats', 38 + }, 39 + ) 40 + 41 + /** 42 + * Count items by requesting a single result and reading the last page 43 + * number from the Link header. 44 + */ 45 + async function fetchPageCount(url: string): Promise<number> { 46 + const response = await fetch(url, { headers: GITHUB_HEADERS }) 47 + 48 + if (!response.ok) { 49 + throw createError({ statusCode: response.status, message: `Failed to fetch ${url}` }) 50 + } 51 + 52 + const link = response.headers.get('link') 53 + if (link) { 54 + const match = link.match(/[?&]page=(\d+)>;\s*rel="last"/) 55 + if (match?.[1]) { 56 + return Number.parseInt(match[1], 10) 57 + } 58 + } 59 + 60 + // No Link header means only one page — count the response body 61 + const body = (await response.json()) as unknown[] 62 + return body.length 63 + } 64 + 65 + /** 66 + * Use the GitHub search API to get a total_count for issues/PRs. 67 + */ 68 + async function fetchSearchCount(type: 'issues', query: string): Promise<number> { 69 + const response = await fetch( 70 + `https://api.github.com/search/${type}?q=${encodeURIComponent(query)}&per_page=1`, 71 + { headers: GITHUB_HEADERS }, 72 + ) 73 + 74 + if (!response.ok) { 75 + throw createError({ statusCode: response.status, message: `Failed to fetch ${type} count` }) 76 + } 77 + 78 + const data = (await response.json()) as GitHubSearchResponse 79 + return data.total_count 80 + }
+1
server/middleware/canonical-redirects.global.ts
··· 25 25 '/privacy', 26 26 '/search', 27 27 '/settings', 28 + '/recharging', 28 29 ] 29 30 30 31 const cacheControl = 's-maxage=3600, stale-while-revalidate=36000'
+13
test/nuxt/a11y.spec.ts
··· 115 115 AppHeader, 116 116 AppLogo, 117 117 BaseCard, 118 + BlueskyPostEmbed, 118 119 BuildEnvironment, 119 120 ButtonBase, 120 121 LinkBase, ··· 2460 2461 versions: mockVersions, 2461 2462 distTags: mockDistTags, 2462 2463 urlPattern: '/package/vue/v/{version}', 2464 + }, 2465 + }) 2466 + const results = await runAxe(component) 2467 + expect(results.violations).toEqual([]) 2468 + }) 2469 + }) 2470 + 2471 + describe('BlueskyPostEmbed', () => { 2472 + it('should have no accessibility violations in pending state', async () => { 2473 + const component = await mountSuspended(BlueskyPostEmbed, { 2474 + props: { 2475 + uri: 'at://did:plc:u5zp7npt5kpueado77kuihyz/app.bsky.feed.post/3mejzn5mrcc2g', 2463 2476 }, 2464 2477 }) 2465 2478 const results = await runAxe(component)