Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#secure password, can use openssl rand -hex 32
NUXT_SESSION_PASSWORD=""

#HMAC secret for image proxy URL signing, can use openssl rand -hex 32
#HMAC secret for image-proxy and OG image URL signing, can use openssl rand -hex 32
NUXT_IMAGE_PROXY_SECRET=""
2 changes: 2 additions & 0 deletions .github/ISSUE_TEMPLATE/bug-report.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
name: "\U0001F41E Bug report"
description: Create a report to help us improve npmx
type: bug
labels: ['pending triage']
body:
- type: markdown
attributes:
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature-request.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: '🚀 Feature request'
description: Suggest a feature that will improve npmx
type: feature
labels: ['pending triage']
body:
- type: markdown
Expand Down
65 changes: 65 additions & 0 deletions .storybook/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,68 @@ export const pdsUsersHandler = http.get('/api/atproto/pds-users', () => {
},
])
})

export const i18nStatusHandler = http.get('/lunaria/status.json', () => {
return HttpResponse.json({
generatedAt: '2026-01-22T10:07:07.000Z',
sourceLocale: {
lang: 'en',
label: 'English',
totalKeys: 500,
},
locales: [
{
lang: 'en-GB',
label: 'English (UK)',
dir: 'ltr',
totalKeys: 500,
completedKeys: 423,
percentComplete: 84,
missingKeys: [
'settings.background_themes.label',
'settings.enable_graph_pulse_loop',
'settings.enable_graph_pulse_loop_description',
'settings.data_source.algolia_description',
'settings.data_source.npm_description',
'i18n.contribute_hint',
'i18n.copy_keys',
],
githubEditUrl: 'https://github.com/npmx-dev/npmx.dev/edit/main/i18n/locales/en-GB.json',
githubHistoryUrl:
'https://github.com/npmx-dev/npmx.dev/commits/main/i18n/locales/en-GB.json',
},
{
lang: 'fr-FR',
label: 'Français',
dir: 'ltr',
totalKeys: 500,
completedKeys: 423,
percentComplete: 84,
missingKeys: [
'settings.background_themes.label',
'settings.enable_graph_pulse_loop',
'settings.enable_graph_pulse_loop_description',
'settings.data_source.algolia_description',
'settings.data_source.npm_description',
'i18n.contribute_hint',
'i18n.copy_keys',
],
githubEditUrl: 'https://github.com/npmx-dev/npmx.dev/edit/main/i18n/locales/fr-FR.json',
githubHistoryUrl:
'https://github.com/npmx-dev/npmx.dev/commits/main/i18n/locales/fr-FR.json',
},
{
lang: 'de-DE',
label: 'Deutsch',
dir: 'ltr',
totalKeys: 500,
completedKeys: 500,
percentComplete: 100,
missingKeys: [],
githubEditUrl: 'https://github.com/npmx-dev/npmx.dev/edit/main/i18n/locales/de-DE.json',
githubHistoryUrl:
'https://github.com/npmx-dev/npmx.dev/commits/main/i18n/locales/de-DE.json',
},
],
})
})
16 changes: 16 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,19 @@
background-color: var(--bg, oklch(0.171 0 0)) !important;
}
</style>
<script>
// related: https://github.com/npmx-dev/npmx.dev/blob/1431d24be555bca5e1ae6264434d49ca15173c43/test/nuxt/setup.ts#L12-L26
// Stub Nuxt specific globals
// @nuxtjs/color-mode's plugin.client.js reads window[globalName] at module
// evaluation time — before any Storybook setup() callback runs — so the
// global must exist in the HTML head, not in preview.ts.
window.__NUXT_COLOR_MODE__ ??= {
preference: 'system',
value: 'dark',
getColorScheme: function () {
return 'dark'
},
addColorScheme: function () {},
removeColorScheme: function () {},
}
</script>
12 changes: 1 addition & 11 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,8 @@ import npmxDark from './theme'

initialize()

// related: https://github.com/npmx-dev/npmx.dev/blob/1431d24be555bca5e1ae6264434d49ca15173c43/test/nuxt/setup.ts#L12-L26
// Stub Nuxt specific globals
// @ts-expect-error - dynamic global name
globalThis['__NUXT_COLOR_MODE__'] ??= {
preference: 'system',
value: 'dark',
getColorScheme: fn(() => 'dark'),
addColorScheme: fn(),
removeColorScheme: fn(),
}
// @ts-expect-error - dynamic global name
globalThis.defineOgImageComponent = fn()
globalThis.defineOgImage = fn()

// Subscribe to locale changes from storybook-i18n addon (once, outside decorator)
let currentI18nInstance: any = null
Expand Down
4 changes: 4 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ if (import.meta.client) {
useEventListener(document, 'click', handleModalLightDismiss)
}
}
// title and description will be inferred
// this will be overridden by upstream pages that use different templates
defineOgImage('Page.takumi', {}, { alt: 'npmx — a fast, modern browser for the npm registry' })
</script>

<template>
Expand Down
4 changes: 4 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ const closeModal = () => modalRef.value?.close?.()
<kbd class="kbd">f</kbd>
<span>{{ $t('shortcuts.open_diff') }}</span>
</li>
<li class="flex gap-2 items-center">
<kbd class="kbd">t</kbd>
<span>{{ $t('shortcuts.open_timeline') }}</span>
</li>
<li class="flex gap-2 items-center">
<kbd class="kbd">c</kbd>
<span>{{ $t('shortcuts.compare_from_package') }}</span>
Expand Down
4 changes: 4 additions & 0 deletions app/components/Chart/SplitSparkline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const props = defineProps<{

const { locale } = useI18n()
const colorMode = useColorMode()
const numberFormatter = useNumberFormatter()
const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(null)
const palette = getPalette('')
Expand Down Expand Up @@ -153,6 +154,9 @@ const configs = computed(() => {
fontSize: 24,
bold: false,
color: colors.value.fg,
formatter: ({ value }) => {
return numberFormatter.value.format(value)
},
datetimeFormatter: {
enable: true,
locale: locale.value,
Expand Down
22 changes: 22 additions & 0 deletions app/components/OgBrand.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
height?: number
}>(),
{
height: 60,
},
)

const width = computed(() => Math.round(props.height * (602 / 170)))
</script>

<template>
<img
src="/logo.svg"
alt="npmx"
:width="width"
:height="height"
:style="{ width: `${width}px`, height: `${height}px` }"
/>
</template>
13 changes: 0 additions & 13 deletions app/components/OgImage/BlogPost.d.vue.ts

This file was deleted.

115 changes: 115 additions & 0 deletions app/components/OgImage/BlogPost.takumi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { ResolvedAuthor } from '#shared/schemas/blog'

const {
title,
authors = [],
date = '',
} = defineProps<{
title: string
authors?: ResolvedAuthor[]
date?: string
}>()

const formattedDate = computed(() => {
if (!date) return ''
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date

return parsed.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
})

const MAX_VISIBLE_AUTHORS = 2

const getInitials = (name: string) =>
name
.trim()
.split(/\s+/)
.map(part => part[0] ?? '')
.join('')
.toUpperCase()
.slice(0, 2)

const visibleAuthors = computed(() => {
if (authors.length <= 3) return authors
return authors.slice(0, MAX_VISIBLE_AUTHORS)
})

const extraCount = computed(() => {
if (authors.length <= 3) return 0
return authors.length - MAX_VISIBLE_AUTHORS
})

const formattedAuthorNames = computed(() => {
const allNames = authors.map(a => a.name)
if (allNames.length === 0) return ''
if (allNames.length === 1) return allNames[0]
if (allNames.length === 2) return `${allNames[0]} and ${allNames[1]}`
if (allNames.length === 3) return `${allNames[0]}, ${allNames[1]}, and ${allNames[2]}`
const shown = allNames.slice(0, MAX_VISIBLE_AUTHORS)
const remaining = allNames.length - MAX_VISIBLE_AUTHORS
return `${shown.join(', ')} and ${remaining} others`
})
</script>

<template>
<OgLayout>
<div class="px-15 py-12 flex flex-col justify-center gap-5 h-full">
<OgBrand :height="48" />

<!-- Date + Title -->
<div class="flex flex-col gap-2">
<span v-if="formattedDate" class="text-3xl text-fg-muted">
{{ formattedDate }}
</span>

<div
class="lg:text-6xl text-5xl tracking-tighter font-mono leading-tight"
:style="{ lineClamp: 2, textOverflow: 'ellipsis' }"
>
{{ title }}
</div>
</div>

<!-- Authors -->
<div v-if="authors.length" class="flex items-center gap-4 flex-nowrap">
<!-- Stacked avatars -->
<span class="flex flex-row items-center">
<span
v-for="(author, index) in visibleAuthors"
:key="author.name"
class="flex items-center justify-center rounded-full border border-bg bg-bg-muted overflow-hidden w-12 h-12"
:style="{ marginLeft: index > 0 ? '-20px' : '0' }"
>
<img
v-if="author.avatar"
:src="author.avatar"
:alt="author.name"
width="48"
height="48"
class="w-full h-full object-cover"
/>
<span v-else class="text-5 text-fg-muted font-medium">
{{ getInitials(author.name) }}
</span>
</span>
<!-- +N badge -->
<span
v-if="extraCount > 0"
class="flex items-center justify-center text-lg font-medium text-fg-muted rounded-full border border-bg bg-bg-muted overflow-hidden w-12 h-12"
:style="{ marginLeft: '-20px' }"
>
+{{ extraCount }}
</span>
</span>
<!-- Names -->
<span class="text-6 text-fg-muted font-light">{{ formattedAuthorNames }}</span>
</div>
</div>
</OgLayout>
</template>
Loading
Loading