diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index fd61f358e..46068963d 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -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,
diff --git a/docs/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue b/docs/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue
index dd1f73d76..ef140c791 100644
--- a/docs/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue
+++ b/docs/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue
@@ -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) {
diff --git a/docs/.vitepress/theme/components/sponsor/SponsorHero.vue b/docs/.vitepress/theme/components/sponsor/SponsorHero.vue
index 681524ee2..557124dd9 100644
--- a/docs/.vitepress/theme/components/sponsor/SponsorHero.vue
+++ b/docs/.vitepress/theme/components/sponsor/SponsorHero.vue
@@ -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 }));
@@ -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 {
diff --git a/docs/.vitepress/theme/components/sponsor/SponsorStats.data.ts b/docs/.vitepress/theme/components/sponsor/SponsorStats.data.ts
index 8c815704f..c974ac03a 100644
--- a/docs/.vitepress/theme/components/sponsor/SponsorStats.data.ts
+++ b/docs/.vitepress/theme/components/sponsor/SponsorStats.data.ts
@@ -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
+ >,
+ ...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,
};
},
diff --git a/docs/.vitepress/theme/components/sponsor/SponsorTierCard.vue b/docs/.vitepress/theme/components/sponsor/SponsorTierCard.vue
index 3e6b3b404..d4df714e3 100644
--- a/docs/.vitepress/theme/components/sponsor/SponsorTierCard.vue
+++ b/docs/.vitepress/theme/components/sponsor/SponsorTierCard.vue
@@ -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));
@@ -59,13 +54,13 @@ const previousTier = computed(() => getPreviousTier(props.tier.id));
sponsors.filter(({ monthlyAmount }) => getTierForAmount(monthlyAmount)?.id === tierId);
diff --git a/docs/.vitepress/theme/components/sponsor/tierLinks.ts b/docs/.vitepress/theme/components/sponsor/tierLinks.ts
new file mode 100644
index 000000000..5e4bdda95
--- /dev/null
+++ b/docs/.vitepress/theme/components/sponsor/tierLinks.ts
@@ -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 = {
+ supporter: {
+ openCollective: '',
+ githubSponsors: '',
+ },
+ backer: {
+ openCollective: '',
+ githubSponsors: '',
+ },
+ bronze: {
+ openCollective: '',
+ githubSponsors: '',
+ },
+ gold: {
+ openCollective: '',
+ githubSponsors: '',
+ },
+ platinum: {
+ openCollective: '',
+ githubSponsors: '',
+ },
+};
diff --git a/docs/.vitepress/theme/components/sponsor/tiers.ts b/docs/.vitepress/theme/components/sponsor/tiers.ts
index 480d25289..d2b3fab04 100644
--- a/docs/.vitepress/theme/components/sponsor/tiers.ts
+++ b/docs/.vitepress/theme/components/sponsor/tiers.ts
@@ -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')}`;
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
index ed5af6873..a5d27919c 100644
--- a/docs/.vitepress/theme/index.ts
+++ b/docs/.vitepress/theme/index.ts
@@ -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 = {
'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),
});
diff --git a/docs/.vitepress/theme/layouts/NavBarSponsorButton.vue b/docs/.vitepress/theme/layouts/NavBarSponsorButton.vue
new file mode 100644
index 000000000..373194a31
--- /dev/null
+++ b/docs/.vitepress/theme/layouts/NavBarSponsorButton.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
diff --git a/docs/scripts/writeSponsorsData.mts b/docs/scripts/writeSponsorsData.mts
index 877b08ae0..ed122e93a 100644
--- a/docs/scripts/writeSponsorsData.mts
+++ b/docs/scripts/writeSponsorsData.mts
@@ -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 = { '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}`);