feat(docs): add navbar sponsor button, more sponsor stats and tier links

- Replace the Sponsor nav text link with a dedicated heart button in the
  navbar, shown icon-only at tablet widths where the menu is crowded
- Show weekly npm downloads across all Lucide packages and the all-time
  Open Collective sponsor count in the sponsor page hero
- Add tierLinks.ts for direct Open Collective and GitHub Sponsors tier
  checkout links, falling back to the general pages while empty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric
2026-10-29 14:21:16 +01:00
co-authored by Claude Opus 5.5
parent 16c72ca3d9
commit 50db2b2a39
11 changed files with 245 additions and 35 deletions
-1
View File
@@ -118,7 +118,6 @@ export default defineConfig({
},
{ text: 'Packages', link: '/packages' },
{ text: 'Showcase', link: '/showcase' },
{ text: 'Sponsor', link: '/sponsor' },
{ text: 'Merch', link: 'https://merch.lucide.dev/' },
],
sidebar,
@@ -5,14 +5,7 @@ import Icon from '@lucide/vue/src/Icon';
import { check } from '../../../data/iconNodes';
import SponsorSection from './SponsorSection.vue';
import SponsorSectionHeader from './SponsorSectionHeader.vue';
import {
GITHUB_SPONSORS_URL,
OPEN_COLLECTIVE_URL,
TIERS,
formatEuro,
getPreviousTier,
getTierForAmount,
} from './tiers';
import { TIERS, formatEuro, getPreviousTier, getTierForAmount, getTierLinks } from './tiers';
type Frequency = 'monthly' | 'once';
@@ -36,6 +29,9 @@ const total = computed(() => {
const tier = computed(() => (isMonthly.value ? getTierForAmount(total.value) : null));
// Only a monthly preset matches a tier's checkout, other amounts go to the general pages.
const links = computed(() => getTierLinks(customAmount.value === '' ? tier.value?.id : undefined));
const amountText = computed(() => formatEuro(total.value));
const summary = computed(() => {
@@ -159,11 +155,11 @@ function pickAmount(value: number) {
</div>
<div class="summary-actions">
<VPButton
:href="OPEN_COLLECTIVE_URL"
:href="links.openCollective"
text="Open Collective"
/>
<VPButton
:href="GITHUB_SPONSORS_URL"
:href="links.githubSponsors"
text="GitHub Sponsors"
theme="alt"
/>
@@ -2,18 +2,32 @@
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
import SponsorSection from './SponsorSection.vue';
import { data } from './SponsorStats.data';
import { sponsors } from './sponsors';
import { allTimeSponsorCount } from './sponsors';
/** Rounds down to a short "120M+" / "25k+" figure. */
function formatCompact(value: number) {
if (value >= 1_000_000) return `${Math.floor(value / 1_000_000)}M+`;
if (value >= 1_000) return `${Math.floor(value / 1_000)}k+`;
return value.toLocaleString('en-US');
}
const stats = [
...(data.stars != null
? [{ value: `${Math.floor(data.stars / 1000)}k+`, label: 'GitHub stars' }]
: []),
{ value: data.weeklyDownloads, label: 'Weekly downloads', format: formatCompact },
{ value: data.stars, label: 'GitHub stars', format: formatCompact },
{
value: `${(Math.floor(data.iconsCount / 100) * 100).toLocaleString('en-US')}+`,
value: data.iconsCount,
label: 'Icons',
format: (value: number) => `${(Math.floor(value / 100) * 100).toLocaleString('en-US')}+`,
},
{ value: sponsors.length.toLocaleString('en-US'), label: 'Sponsors' },
];
{
value: allTimeSponsorCount,
label: 'All-time sponsors',
format: (value: number) => value.toLocaleString('en-US'),
},
]
// Stats the build couldn't fetch are left out.
.filter((stat): stat is typeof stat & { value: number } => stat.value != null)
.map(({ value, label, format }) => ({ value: format(value), label }));
</script>
<template>
@@ -111,9 +125,8 @@ const stats = [
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px 16px;
}
.stat {
@@ -1,4 +1,12 @@
import iconNodes from '../../../data/iconNodes';
import fetchPackages from '../../../lib/fetchPackages';
const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point/last-week';
/**
* Packages that are no longer in the repo but are still published and installed.
*/
const LEGACY_PACKAGES = ['lucide-vue-next', 'lucide-vue', 'lucide-svelte', 'lucide-angular'];
async function fetchStars() {
try {
@@ -13,10 +21,56 @@ async function fetchStars() {
}
}
async function fetchDownloads(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`npm downloads API responded with ${response.status} for ${url}`);
}
return response.json();
}
/**
* Sum of last week's npm downloads of every published Lucide package, or `null` when any
* request fails, so a partial total never shows up as the real number.
*/
async function fetchWeeklyDownloads() {
try {
const packageNames = (await fetchPackages())
.filter((packageJson) => packageJson?.name && !packageJson.private)
.map(({ name }) => name)
.concat(LEGACY_PACKAGES);
// The bulk endpoint only accepts unscoped packages, scoped ones are fetched one by one.
const unscoped = packageNames.filter((name) => !name.startsWith('@'));
const scoped = packageNames.filter((name) => name.startsWith('@'));
const [bulk, ...single] = await Promise.all([
fetchDownloads(`${NPM_DOWNLOADS_API}/${unscoped.join(',')}`) as Promise<
Record<string, { downloads: number } | null>
>,
...scoped.map(
(name) => fetchDownloads(`${NPM_DOWNLOADS_API}/${name}`) as Promise<{ downloads: number }>,
),
]);
return [...Object.values(bulk), ...single].reduce(
(total, result) => total + (result?.downloads ?? 0),
0,
);
} catch (error) {
console.warn(`Could not fetch npm downloads. ${error}`);
return null;
}
}
const loader = {
async load() {
const [stars, weeklyDownloads] = await Promise.all([fetchStars(), fetchWeeklyDownloads()]);
return {
stars: await fetchStars(),
stars,
weeklyDownloads,
iconsCount: Object.keys(iconNodes).length,
};
},
@@ -3,19 +3,14 @@ import { computed } from 'vue';
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
import Icon from '@lucide/vue/src/Icon';
import { check } from '../../../data/iconNodes';
import {
GITHUB_SPONSORS_URL,
OPEN_COLLECTIVE_URL,
formatEuro,
getPreviousTier,
type Tier,
} from './tiers';
import { formatEuro, getPreviousTier, getTierLinks, type Tier } from './tiers';
const props = defineProps<{
tier: Tier;
}>();
const previousTier = computed(() => getPreviousTier(props.tier.id));
const links = computed(() => getTierLinks(props.tier.id));
</script>
<template>
@@ -59,13 +54,13 @@ const previousTier = computed(() => getPreviousTier(props.tier.id));
<div class="actions">
<VPButton
:href="OPEN_COLLECTIVE_URL"
:href="links.openCollective"
text="Open Collective"
:theme="tier.featured ? 'brand' : 'alt'"
class="primary-action"
/>
<a
:href="GITHUB_SPONSORS_URL"
:href="links.githubSponsors"
target="_blank"
rel="noreferrer"
class="secondary-action"
@@ -12,6 +12,9 @@ export interface Sponsor {
export const sponsors: Sponsor[] = sponsorsData.sponsors;
/** Everyone who ever contributed on Open Collective, `null` when the build couldn't fetch it. */
export const allTimeSponsorCount: number | null = sponsorsData.allTimeCount;
export const getSponsorsByTier = (tierId: TierId) =>
sponsors.filter(({ monthlyAmount }) => getTierForAmount(monthlyAmount)?.id === tierId);
@@ -0,0 +1,33 @@
import type { TierId } from './tiers';
/**
* Direct checkout links for each sponsor tier. Leave a link empty to fall back to the
* general Open Collective or GitHub Sponsors page.
*
* - Open Collective: open the tier on opencollective.com/lucide-icons and copy its
* "Contribute" link, e.g. https://opencollective.com/lucide-icons/contribute/backer-12345/checkout
* - GitHub Sponsors: open the tier on github.com/sponsors/lucide-icons and copy its link,
* e.g. https://github.com/sponsors/lucide-icons/sponsorships?tier_id=12345
*/
export const TIER_LINKS: Record<TierId, { openCollective: string; githubSponsors: string }> = {
supporter: {
openCollective: '',
githubSponsors: '',
},
backer: {
openCollective: '',
githubSponsors: '',
},
bronze: {
openCollective: '',
githubSponsors: '',
},
gold: {
openCollective: '',
githubSponsors: '',
},
platinum: {
openCollective: '',
githubSponsors: '',
},
};
@@ -1,3 +1,5 @@
import { TIER_LINKS } from './tierLinks';
export const OPEN_COLLECTIVE_URL = 'https://opencollective.com/lucide-icons';
export const GITHUB_SPONSORS_URL = 'https://github.com/sponsors/lucide-icons';
export const PARTNER_EMAIL = 'info@lucide.dev';
@@ -89,4 +91,14 @@ export function getTierForAmount(monthly: number) {
return [...TIERS].reverse().find((tier) => monthly >= tier.monthly) ?? null;
}
/** Checkout links for a tier, or the general sponsor pages without a tier. */
export function getTierLinks(id?: TierId) {
const links = id ? TIER_LINKS[id] : undefined;
return {
openCollective: links?.openCollective || OPEN_COLLECTIVE_URL,
githubSponsors: links?.githubSponsors || GITHUB_SPONSORS_URL,
};
}
export const formatEuro = (amount: number) => `€${amount.toLocaleString('en-US')}`;
+2
View File
@@ -11,6 +11,7 @@ import { ICON_STYLE_CONTEXT, iconStyleContext } from './composables/useIconStyle
import { CATEGORY_VIEW_CONTEXT, categoryViewContext } from './composables/useCategoryView';
import { EXTERNAL_LIBS_CONTEXT, externalLibContext } from './composables/useExternalLibs';
import SideBarNavBefore from './layouts/SideBarNavBefore.vue';
import NavBarSponsorButton from './layouts/NavBarSponsorButton.vue';
import SnackPlayer from './components/editors/SnackPlayer.vue';
import { setupTracking } from './utils/openpanel';
@@ -23,6 +24,7 @@ const theme: Partial<Theme> = {
'sidebar-nav-after': () => h(IconsSidebarNavAfter),
'home-hero-image': () => h(HomeHeroIconsCard),
'home-hero-actions-after': () => h(HomeHeroAfter),
'nav-bar-content-after': () => h(NavBarSponsorButton),
// Keep this here for now, we might want to add more things to the top bar in the future
// 'layout-top': () => h(LayoutTop),
});
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useData } from 'vitepress';
import Icon from '@lucide/vue/src/Icon';
import { heart } from '../../data/iconNodes';
const { page } = useData();
const isActive = computed(() => page.value.relativePath === 'sponsor.md');
</script>
<template>
<a
href="/sponsor"
class="sponsor-button"
:class="{ active: isActive }"
:aria-current="isActive ? 'page' : undefined"
>
<Icon
:iconNode="heart"
name="heart"
:size="16"
class="heart"
aria-hidden="true"
/>
<span class="label">Sponsor</span>
</a>
</template>
<style scoped>
.sponsor-button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 16px;
border: 1px solid var(--vp-c-divider);
background-color: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
text-decoration: none;
transition:
border-color 0.25s,
color 0.25s;
}
.heart {
color: var(--vp-c-brand-3);
transition:
fill 0.25s,
transform 0.25s;
}
.sponsor-button:hover,
.sponsor-button.active {
border-color: var(--vp-c-brand-3);
}
.sponsor-button:hover .heart,
.sponsor-button.active .heart {
fill: currentColor;
}
.sponsor-button:hover .heart {
transform: scale(1.1);
}
@media (min-width: 768px) {
.sponsor-button {
margin-left: 16px;
}
}
/* The nav menu leaves no room for the label between tablet and desktop widths. */
@media (min-width: 768px) and (max-width: 959px) {
.sponsor-button {
justify-content: center;
width: 32px;
padding: 0;
margin-left: 8px;
}
.label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
</style>
+12 -4
View File
@@ -33,6 +33,7 @@ interface Order {
interface CollectiveResponse {
data?: {
account: {
stats: { contributorsCount: number | null } | null;
orders: { nodes: Order[] };
} | null;
};
@@ -54,6 +55,9 @@ export interface Sponsor {
const query = /* GraphQL */ `
query Sponsors($slug: String!) {
account(slug: $slug) {
stats {
contributorsCount
}
orders(filter: INCOMING, status: [ACTIVE], limit: 1000) {
nodes {
amount {
@@ -72,7 +76,7 @@ const query = /* GraphQL */ `
}
`;
async function fetchOrders() {
async function fetchCollective() {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (process.env.OPENCOLLECTIVE_TOKEN) {
headers['Personal-Token'] = process.env.OPENCOLLECTIVE_TOKEN;
@@ -98,7 +102,7 @@ async function fetchOrders() {
throw new Error(`Could not find Open Collective account "${COLLECTIVE_SLUG}"`);
}
return data.account.orders.nodes;
return data.account;
}
function getLogo({ slug, imageUrl }: Account) {
@@ -149,13 +153,17 @@ function toSponsors(orders: Order[]) {
}
let sponsors: Sponsor[] = [];
/** Everyone who ever contributed financially, including past and one-time contributors. */
let allTimeCount: number | null = null;
try {
sponsors = toSponsors(await fetchOrders());
const collective = await fetchCollective();
sponsors = toSponsors(collective.orders.nodes);
allTimeCount = collective.stats?.contributorsCount ?? null;
} catch (error) {
console.warn(`Could not fetch sponsors, writing an empty list. ${error}`);
}
fs.mkdirSync(dataDirectory, { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify({ sponsors }, null, 2), 'utf-8');
fs.writeFileSync(outputPath, JSON.stringify({ allTimeCount, sponsors }, null, 2), 'utf-8');
console.log(`Wrote ${sponsors.length} sponsors to ${outputPath}`);