+ Lucide is built and maintained by a small team and a big community. Your sponsorship pays
+ for icon reviews, new packages and the hours that keep every release dependable.
+
+ One partner at a time, custom amount. An exclusive "Supported by" slot, your logo in
+ release posts, an annual roadmap call and priority review of your icon PRs.
+
+
+
+
+
+
+ Sponsors never buy icon acceptance or design influence. Every icon meets the same guidelines
+ and community review. Paying by invoice through GitHub Sponsors avoids part of GitHub's
+ organization fee.
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/sponsor/SponsorWall.vue b/docs/.vitepress/theme/components/sponsor/SponsorWall.vue
new file mode 100644
index 000000000..7825837de
--- /dev/null
+++ b/docs/.vitepress/theme/components/sponsor/SponsorWall.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/sponsor/sponsors.ts b/docs/.vitepress/theme/components/sponsor/sponsors.ts
new file mode 100644
index 000000000..498cd6d56
--- /dev/null
+++ b/docs/.vitepress/theme/components/sponsor/sponsors.ts
@@ -0,0 +1,26 @@
+import sponsorsData from '../../../data/sponsorsData.json';
+import type { TierId } from './tiers';
+
+export interface Sponsor {
+ slug: string;
+ name: string;
+ website: string;
+ image: string;
+ imageDark?: string;
+ avatar: string;
+ monthlyAmount: number;
+ tier: TierId | null;
+}
+
+export const sponsors = sponsorsData.sponsors as Sponsor[];
+
+/** 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(({ tier }) => tier === tierId);
+
+const LOGO_TIERS: TierId[] = ['bronze', 'gold', 'platinum'];
+
+/** Everyone below the first logo tier, shown as avatars. */
+export const getSupporters = () =>
+ sponsors.filter(({ tier }) => !tier || !LOGO_TIERS.includes(tier));
diff --git a/docs/.vitepress/theme/components/sponsor/tiers.ts b/docs/.vitepress/theme/components/sponsor/tiers.ts
new file mode 100644
index 000000000..bbb533f9d
--- /dev/null
+++ b/docs/.vitepress/theme/components/sponsor/tiers.ts
@@ -0,0 +1,170 @@
+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';
+
+export type TierId = 'supporter' | 'backer' | 'bronze' | 'gold' | 'platinum';
+
+export interface Tier {
+ id: TierId;
+ name: string;
+ /** Monthly amount in euros. */
+ monthly: number;
+ /** Yearly amount in euros, for tiers that can be paid yearly. */
+ yearly?: number;
+ audience: string;
+ /** One-sentence summary of the perks, used in the amount picker. */
+ summary: string;
+ /** Perks this tier adds on top of the previous one. */
+ perks: string[];
+ featured?: boolean;
+ /**
+ * Tier slug on Open Collective. Open the tier on opencollective.com/lucide-icons and copy
+ * it from the "Contribute" link, e.g. `basic-73095` from
+ * https://opencollective.com/lucide-icons/contribute/basic-73095/checkout
+ */
+ openCollectiveTier: string;
+ /**
+ * Tier id on GitHub Sponsors. Open the tier on github.com/sponsors/lucide-icons and copy
+ * the `tier_id` from its link, e.g. `671689` from
+ * https://github.com/sponsors/lucide-icons/sponsorships?tier_id=671689
+ */
+ githubSponsorsTierId: number;
+}
+
+export const TIERS: Tier[] = [
+ {
+ id: 'supporter',
+ name: 'Supporter',
+ monthly: 5,
+ audience: 'For individuals',
+ summary:
+ 'Sponsor badge on GitHub, a Discord sponsor role and your avatar on lucide.dev/sponsor.',
+ perks: [
+ 'Sponsor badge on GitHub',
+ 'Discord sponsor role',
+ 'Small avatar on lucide.dev/sponsor',
+ ],
+ openCollectiveTier: 'basic-73095',
+ githubSponsorsTierId: 671689,
+ },
+ {
+ id: 'backer',
+ name: 'Backer',
+ monthly: 20,
+ audience: 'For individuals and freelancers',
+ summary: 'Everything in Supporter, plus a small avatar or logo in the main README.',
+ perks: ['Small avatar or logo in the main README on GitHub'],
+ openCollectiveTier: 'awesome-backer-73677',
+ githubSponsorsTierId: 671692,
+ },
+ {
+ id: 'bronze',
+ name: 'Bronze',
+ monthly: 100,
+ audience: 'For small companies and startups',
+ summary: 'Small logo on lucide.dev/sponsor and in all npm READMEs, plus a sticker sheet.',
+ perks: [
+ 'Small logo on lucide.dev/sponsor',
+ 'Small logo in all npm package READMEs',
+ 'Free Lucide sticker sheet',
+ ],
+ openCollectiveTier: 'donation-55643',
+ githubSponsorsTierId: 675253,
+ },
+ {
+ id: 'gold',
+ name: 'Gold',
+ monthly: 500,
+ yearly: 5000,
+ audience: 'For companies shipping Lucide',
+ summary: 'Medium logo on the lucide.dev homepage and docs sidebar, plus a welcome t-shirt.',
+ perks: [
+ 'Medium logo in the main README on GitHub',
+ 'Medium logo in all npm package READMEs',
+ 'Medium logo in the lucide.dev homepage sponsor strip and docs sidebar',
+ 'Medium logo on lucide.dev/sponsor',
+ 'One-time welcome t-shirt',
+ ],
+ featured: true,
+ openCollectiveTier: 'champions-circle-73678',
+ githubSponsorsTierId: 675254,
+ },
+ {
+ id: 'platinum',
+ name: 'Platinum',
+ monthly: 1000,
+ yearly: 10000,
+ audience: 'For large companies and dev tools',
+ summary:
+ 'Top homepage placement, logo on icon search, a private maintainer channel and a $100 merch voucher.',
+ perks: [
+ 'Top placement on the homepage',
+ 'Large logo at the top of the main README on GitHub',
+ 'Large logo at the top of all npm package READMEs',
+ 'Large logo in the lucide.dev homepage sponsor strip and docs sidebar',
+ 'Logo on the icon search page',
+ 'Dedicated welcome post on X and Bluesky',
+ 'Private channel with maintainers',
+ '$100 merch shop voucher',
+ ],
+ openCollectiveTier: 'platinum-107266',
+ githubSponsorsTierId: 675255,
+ },
+];
+
+export const getTier = (id: TierId) => TIERS.find((tier) => tier.id === id)!;
+
+/** The tier before this one, whose perks it inherits. */
+export const getPreviousTier = (id: TierId) => TIERS[TIERS.findIndex((tier) => tier.id === id) - 1];
+
+/** The highest tier a monthly amount qualifies for, or `null` below the lowest tier. */
+export function getTierForAmount(monthly: number) {
+ return [...TIERS].reverse().find((tier) => monthly >= tier.monthly) ?? null;
+}
+
+/**
+ * The highest tier a yearly total qualifies for, or `null` below the lowest tier. Tiers with a
+ * yearly price (two months free) count that price, so a yearly Gold sponsor isn't listed as Bronze.
+ */
+export function getTierForYearlyAmount(yearly: number) {
+ return [...TIERS].reverse().find((tier) => yearly >= (tier.yearly ?? tier.monthly * 12)) ?? null;
+}
+
+export type Frequency = 'monthly' | 'once';
+
+const getOpenCollectiveParams = (amount: number, frequency: Frequency) =>
+ new URLSearchParams({
+ interval: frequency === 'monthly' ? 'month' : 'oneTime',
+ amount: String(amount),
+ contributeAs: 'me',
+ });
+
+/** Checkout links for a tier, or the general sponsor pages without a tier. */
+export function getTierLinks(tier?: Tier) {
+ if (!tier) {
+ return { openCollective: OPEN_COLLECTIVE_URL, githubSponsors: GITHUB_SPONSORS_URL };
+ }
+
+ const openCollectiveParams = getOpenCollectiveParams(tier.monthly, 'monthly');
+
+ return {
+ openCollective: `${OPEN_COLLECTIVE_URL}/contribute/${tier.openCollectiveTier}/checkout?${openCollectiveParams}`,
+ githubSponsors: `${GITHUB_SPONSORS_URL}/sponsorships?tier_id=${tier.githubSponsorsTierId}`,
+ };
+}
+
+/** Checkout links prefilled with a custom amount and frequency. */
+export function getAmountLinks(amount: number, frequency: Frequency) {
+ const openCollectiveParams = getOpenCollectiveParams(amount, frequency);
+ const githubSponsorsParams = new URLSearchParams({
+ frequency: frequency === 'monthly' ? 'recurring' : 'one-time',
+ amount: String(amount),
+ });
+
+ return {
+ openCollective: `${OPEN_COLLECTIVE_URL}/donate?${openCollectiveParams}`,
+ githubSponsors: `${GITHUB_SPONSORS_URL}/sponsorships?${githubSponsorsParams}`,
+ };
+}
+
+export const formatEuro = (amount: number) => `โฌ${amount.toLocaleString('en-US')}`;
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
index 75dbbd050..0834c250e 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';
import trackUnderlineDirection from './utils/underlineDirection';
@@ -24,6 +25,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 @@
+
+
+
+
+
+ Sponsor
+
+
+
+
diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css
index 55b4eef2a..deb181470 100644
--- a/docs/.vitepress/theme/style.css
+++ b/docs/.vitepress/theme/style.css
@@ -68,7 +68,8 @@
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
+ transition-duration: 0s !important;
+ transition-delay: 0s !important;
scroll-behavior: auto !important;
}
diff --git a/docs/.vitepress/transformPageData.ts b/docs/.vitepress/transformPageData.ts
index 85dbc6b61..7ed0cc73d 100644
--- a/docs/.vitepress/transformPageData.ts
+++ b/docs/.vitepress/transformPageData.ts
@@ -1,5 +1,5 @@
import { type PageData } from 'vitepress';
-import getStructuredData from './getStructuredData';
+import getStructuredData, { getFaqStructuredData } from './getStructuredData';
import { createGeneralOGImage, createIconOGImage } from './createOGImage';
export async function transformPageData(pageData: PageData) {
@@ -53,6 +53,15 @@ export async function transformPageData(pageData: PageData) {
]);
}
+ // Pages can list their FAQ in the `faq` frontmatter, rendered by the `Faq` component.
+ if (Array.isArray(pageData.frontmatter.faq)) {
+ pageData.frontmatter.head.push([
+ 'script',
+ { type: 'application/ld+json' },
+ JSON.stringify(getFaqStructuredData(pageData.frontmatter.faq)),
+ ]);
+ }
+
if (pageData.relativePath.startsWith('guide/')) {
const ogPath = await createGeneralOGImage(pageData);
diff --git a/docs/package.json b/docs/package.json
index 988398a4e..eb3ebe7fb 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -17,6 +17,7 @@
"prebuild:iconDetails": "node ./scripts/writeIconDetails.mts",
"prebuild:iconPopularity": "node --env-file-if-exists=.env ./scripts/writeiconPopularity.mts && node ./scripts/writeiconPopularityIndex.mts",
"prebuild:brandStopwords": "node ./scripts/writeBrandStopwords.mts",
+ "prebuild:sponsors": "node --env-file-if-exists=.env ./scripts/writeSponsorsData.mts",
"prebuild:writeLabIconNodes": "node ./scripts/writeLabIconNodes.mts",
"prebuild:writeLabIconMetaIndex": "node ./scripts/writeLabIconMetaIndex.mts",
"postbuild:vercelJson": "node ./scripts/writeVercelOutput.mts",
diff --git a/docs/public/funding.json b/docs/public/funding.json
new file mode 100644
index 000000000..755207406
--- /dev/null
+++ b/docs/public/funding.json
@@ -0,0 +1,85 @@
+{
+ "$schema": "https://fundingjson.org/schema/v1.0.0.json",
+ "version": "v1.0.0",
+ "entity": {
+ "type": "organisation",
+ "role": "steward",
+ "name": "Lucide Icons",
+ "email": "info@lucide.dev",
+ "description": "Lucide Icons is the team that maintains Lucide, a community-driven, open-source icon library. The project is led by Eric Fennis together with co-maintainer Karsa Mistmere.",
+ "webpageUrl": {
+ "url": "https://lucide.dev"
+ }
+ },
+ "projects": [
+ {
+ "guid": "lucide",
+ "name": "Lucide",
+ "description": "Beautiful and consistent icon toolkit made by the community. Lucide provides a large set of open-source SVG icons plus official packages for React, Vue, Svelte, Solid, Preact, Angular, React Native and vanilla JavaScript, along with a Figma plugin. It is free for personal and commercial use under the ISC license.",
+ "webpageUrl": {
+ "url": "https://lucide.dev"
+ },
+ "repositoryUrl": {
+ "url": "https://github.com/lucide-icons/lucide",
+ "wellKnown": "https://github.com/lucide-icons/lucide/blob/main/.well-known/funding-manifest-urls"
+ },
+ "licenses": ["spdx:ISC"],
+ "tags": ["icons", "svg", "design", "user-interface", "react", "vue", "svelte", "figma"]
+ }
+ ],
+ "funding": {
+ "channels": [
+ {
+ "guid": "opencollective",
+ "type": "payment-provider",
+ "address": "https://opencollective.com/lucide-icons",
+ "description": "Recurring or one-time contributions via Open Collective."
+ },
+ {
+ "guid": "github-sponsors-lucide",
+ "type": "payment-provider",
+ "address": "https://github.com/sponsors/lucide-icons",
+ "description": "Sponsor the Lucide project via GitHub Sponsors."
+ },
+ {
+ "guid": "github-sponsors-ericfennis",
+ "type": "payment-provider",
+ "address": "https://github.com/sponsors/ericfennis",
+ "description": "Sponsor the lead maintainer via GitHub Sponsors."
+ }
+ ],
+ "plans": [
+ {
+ "guid": "maintainer-time",
+ "status": "active",
+ "name": "Maintainer time",
+ "description": "Funds 44 hours of maintenance per week: 32 hours for Eric Fennis and 12 hours for Karsa Mistmere at EUR 40 per hour. Covers reviewing icon contributions, maintaining the framework packages, releases, documentation and community support.",
+ "amount": 91520,
+ "currency": "EUR",
+ "frequency": "yearly",
+ "channels": ["opencollective", "github-sponsors-lucide", "github-sponsors-ericfennis"]
+ },
+ {
+ "guid": "infrastructure-tooling",
+ "status": "active",
+ "name": "Infrastructure and tooling",
+ "description": "Covers hosting on DigitalOcean (EUR 1,200/year), the domain (EUR 10/year), design and project tools Figma, Notion and Linear (EUR 900/year) and AI coding assistance (EUR 240/year).",
+ "amount": 2350,
+ "currency": "EUR",
+ "frequency": "yearly",
+ "channels": ["opencollective", "github-sponsors-lucide"]
+ },
+ {
+ "guid": "custom-contribution",
+ "status": "active",
+ "name": "Custom contribution",
+ "description": "Contribute any amount to support Lucide.",
+ "amount": 0,
+ "currency": "EUR",
+ "frequency": "one-time",
+ "channels": ["opencollective", "github-sponsors-lucide", "github-sponsors-ericfennis"]
+ }
+ ],
+ "history": []
+ }
+}
diff --git a/docs/public/sponsors/dark/sourcegraph.svg b/docs/public/sponsors/dark/sourcegraph.svg
new file mode 100644
index 000000000..21f1f9f64
--- /dev/null
+++ b/docs/public/sponsors/dark/sourcegraph.svg
@@ -0,0 +1 @@
+
diff --git a/docs/public/sponsors/dark/zephyr-cloud.svg b/docs/public/sponsors/dark/zephyr-cloud.svg
new file mode 100644
index 000000000..19cc0c9a9
--- /dev/null
+++ b/docs/public/sponsors/dark/zephyr-cloud.svg
@@ -0,0 +1 @@
+
diff --git a/docs/public/sponsors/sourcegraph.svg b/docs/public/sponsors/sourcegraph.svg
new file mode 100644
index 000000000..3c5618a6b
--- /dev/null
+++ b/docs/public/sponsors/sourcegraph.svg
@@ -0,0 +1 @@
+
diff --git a/docs/public/sponsors/zephyr-cloud.svg b/docs/public/sponsors/zephyr-cloud.svg
index 1ebc11f16..b29db400b 100644
--- a/docs/public/sponsors/zephyr-cloud.svg
+++ b/docs/public/sponsors/zephyr-cloud.svg
@@ -1,15 +1 @@
-
+
diff --git a/docs/scripts/helpers/openCollective.mts b/docs/scripts/helpers/openCollective.mts
new file mode 100644
index 000000000..83b054b9c
--- /dev/null
+++ b/docs/scripts/helpers/openCollective.mts
@@ -0,0 +1,164 @@
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { getTierForYearlyAmount } from '../../.vitepress/theme/components/sponsor/tiers.ts';
+
+const COLLECTIVE_SLUG = 'lucide-icons';
+const OPEN_COLLECTIVE_API = 'https://api.opencollective.com/graphql/v2';
+
+const sponsorLogosPath = join(import.meta.dirname, '../../public/sponsors');
+
+const GRACE_PERIOD_DAYS = 31;
+const DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+export interface Account {
+ slug: string;
+ name: string;
+ website: string | null;
+ imageUrl: string | null;
+ description: string | null;
+}
+
+interface Order {
+ status: 'ACTIVE' | 'CANCELLED' | 'ERROR' | 'PAUSED';
+ amount: { valueInCents: number | null } | null;
+ frequency: 'MONTHLY' | 'YEARLY' | 'ONETIME' | null;
+ nextChargeDate: string | null;
+ fromAccount: Account;
+}
+
+interface CollectiveResponse {
+ data?: {
+ account: {
+ stats: { contributorsCount: number | null } | null;
+ orders: { nodes: Order[] };
+ } | null;
+ };
+ errors?: { message: string }[];
+}
+
+const query = /* GraphQL */ `
+ query Sponsors($slug: String!) {
+ account(slug: $slug) {
+ stats {
+ contributorsCount
+ }
+ orders(filter: INCOMING, status: [ACTIVE, CANCELLED, ERROR, PAUSED], limit: 1000) {
+ nodes {
+ status
+ amount {
+ valueInCents
+ }
+ frequency
+ nextChargeDate
+ fromAccount {
+ slug
+ name
+ website
+ imageUrl
+ description
+ }
+ }
+ }
+ }
+ }
+`;
+
+export async function fetchCollective() {
+ const headers: Record = { 'Content-Type': 'application/json' };
+ if (process.env.OPENCOLLECTIVE_TOKEN) {
+ headers['Personal-Token'] = process.env.OPENCOLLECTIVE_TOKEN;
+ }
+
+ const response = await fetch(OPEN_COLLECTIVE_API, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ query, variables: { slug: COLLECTIVE_SLUG } }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Open Collective API responded with ${response.status} ${response.statusText}`);
+ }
+
+ const { data, errors } = (await response.json()) as CollectiveResponse;
+ if (errors?.length) {
+ throw new Error(
+ `Open Collective API errors: ${errors.map(({ message }) => message).join(', ')}`,
+ );
+ }
+ if (!data?.account) {
+ throw new Error(`Could not find Open Collective account "${COLLECTIVE_SLUG}"`);
+ }
+
+ return data.account;
+}
+
+/**
+ * Active contributions, plus recurring ones that stopped (cancelled, failed or paused) less than
+ * a month after their next payment was due. A failed payment doesn't drop a sponsor right away.
+ */
+function isCurrentSponsor({ status, nextChargeDate }: Order) {
+ if (status === 'ACTIVE') return true;
+ if (!nextChargeDate) return false;
+
+ return Date.now() < new Date(nextChargeDate).getTime() + GRACE_PERIOD_DAYS * DAY_IN_MS;
+}
+
+function getYearlyAmount({ amount, frequency }: Order) {
+ const value = (amount?.valueInCents ?? 0) / 100;
+
+ return frequency === 'YEARLY' ? value : value * 12;
+}
+
+/**
+ * The current sponsors with everything they contribute in a year and the tier that qualifies
+ * for, highest amount first.
+ */
+export function getCurrentSponsors(orders: Order[]) {
+ const yearlyAmounts = new Map();
+
+ orders.filter(isCurrentSponsor).forEach((order) => {
+ const sponsor = yearlyAmounts.get(order.fromAccount.slug);
+
+ if (sponsor) {
+ sponsor.yearlyAmount += getYearlyAmount(order);
+ } else {
+ yearlyAmounts.set(order.fromAccount.slug, {
+ account: order.fromAccount,
+ yearlyAmount: getYearlyAmount(order),
+ });
+ }
+ });
+
+ return [...yearlyAmounts.values()]
+ .map(({ account, yearlyAmount }) => {
+ const roundedAmount = Math.round(yearlyAmount * 100) / 100;
+
+ return {
+ account,
+ yearlyAmount: roundedAmount,
+ tier: getTierForYearlyAmount(roundedAmount)?.id ?? null,
+ };
+ })
+ .sort(
+ (a, b) => b.yearlyAmount - a.yearlyAmount || a.account.name.localeCompare(b.account.name),
+ );
+}
+
+const findLogo = (fileName: string) =>
+ ['svg', 'png']
+ .map((extension) => `${fileName}.${extension}`)
+ .find((file) => existsSync(join(sponsorLogosPath, file)));
+
+/**
+ * Custom logos in docs/public/sponsors, as paths relative to that directory.
+ * A logo in docs/public/sponsors/dark is used in dark mode.
+ */
+export function getLocalLogos(slug: string) {
+ return { light: findLogo(slug), dark: findLogo(`dark/${slug}`) };
+}
+
+export const getWebsite = ({ slug, website }: Account) =>
+ website || `https://opencollective.com/${slug}`;
+
+export const getOpenCollectiveLogo = ({ slug, imageUrl }: Account) =>
+ imageUrl ?? `https://images.opencollective.com/${slug}/logo/256.png`;
diff --git a/docs/scripts/writeSponsorsData.mts b/docs/scripts/writeSponsorsData.mts
new file mode 100644
index 000000000..ed3fd0436
--- /dev/null
+++ b/docs/scripts/writeSponsorsData.mts
@@ -0,0 +1,69 @@
+import fs from 'fs';
+import path from 'path';
+import { loadEnvFile } from 'node:process';
+import type { TierId } from '../.vitepress/theme/components/sponsor/tiers.ts';
+import {
+ fetchCollective,
+ getCurrentSponsors,
+ getLocalLogos,
+ getOpenCollectiveLogo,
+ getWebsite,
+} from './helpers/openCollective.mts';
+
+const currentDir = process.cwd();
+const dataDirectory = path.resolve(currentDir, '.vitepress/data');
+const outputPath = path.resolve(dataDirectory, 'sponsorsData.json');
+
+try {
+ // Load environment variables from .env file, if it exists.
+ loadEnvFile(`${currentDir}/.env`);
+} catch {
+ // No .env file, so rely on the environment as-is.
+}
+
+export interface Sponsor {
+ slug: string;
+ name: string;
+ website: string;
+ /** Logo for the logo tiers, preferring a local file in `public/sponsors`. */
+ image: string;
+ /** Logo for dark mode, from `public/sponsors/dark`. */
+ imageDark?: string;
+ /** Square avatar for the backer and supporter tiers. */
+ avatar: string;
+ /** Recurring contribution normalised to a monthly amount, in the collective's currency. */
+ monthlyAmount: number;
+ /** The tier their yearly contribution qualifies for, `null` below the lowest tier. */
+ tier: TierId | null;
+}
+
+let sponsors: Sponsor[] = [];
+/** Everyone who ever contributed financially, including past and one-time contributors. */
+let allTimeCount: number | null = null;
+
+try {
+ const collective = await fetchCollective();
+
+ sponsors = getCurrentSponsors(collective.orders.nodes).map(({ account, yearlyAmount, tier }) => {
+ const logos = getLocalLogos(account.slug);
+
+ return {
+ slug: account.slug,
+ name: account.name,
+ website: getWebsite(account),
+ image: logos.light ? `/sponsors/${logos.light}` : getOpenCollectiveLogo(account),
+ imageDark: logos.dark && `/sponsors/${logos.dark}`,
+ avatar:
+ account.imageUrl ?? `https://images.opencollective.com/${account.slug}/avatar/128.png`,
+ monthlyAmount: Math.round((yearlyAmount / 12) * 100) / 100,
+ tier,
+ };
+ });
+ 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({ allTimeCount, sponsors }, null, 2), 'utf-8');
+console.log(`Wrote ${sponsors.length} sponsors to ${outputPath}`);
diff --git a/docs/sponsor.md b/docs/sponsor.md
new file mode 100644
index 000000000..1397b1be6
--- /dev/null
+++ b/docs/sponsor.md
@@ -0,0 +1,45 @@
+---
+title: Sponsor
+description: Sponsor Lucide to fund icon reviews, new packages and dependable releases. Pick a tier on Open Collective or GitHub Sponsors.
+layout: page
+faq:
+ - question: Will Lucide stay free?
+ answer: Yes. Every icon stays free and open source under the ISC license, whether or not you sponsor.
+ - question: Open Collective or GitHub Sponsors?
+ answer: Both have the same tiers. Open Collective takes card, bank transfer and PayPal in EUR with a public ledger. GitHub Sponsors bills in USD through your existing GitHub account, with invoicing for organizations.
+ - question: Can I pay yearly?
+ answer: Gold and Platinum can be paid yearly at ten times the monthly price, so you get two months free and one invoice.
+ - question: When does my logo appear?
+ answer: Logos come from your Open Collective or GitHub profile. lucide.dev updates within a day. The npm READMEs use a hosted image, so they update without a new release.
+ - question: Does sponsoring get my icons accepted?
+ answer: No. Partners get their icon PRs reviewed first, but every icon meets the same design guidelines and community review.
+ - question: Can I cancel anytime?
+ answer: Yes, recurring contributions can be cancelled anytime on Open Collective or GitHub Sponsors.
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/angular/README.md b/packages/angular/README.md
index 16b9360ba..79560301d 100644
--- a/packages/angular/README.md
+++ b/packages/angular/README.md
@@ -71,24 +71,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/astro/README.md b/packages/astro/README.md
index 9c1a4f7f6..3e8db2e4b 100644
--- a/packages/astro/README.md
+++ b/packages/astro/README.md
@@ -68,24 +68,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/icons/README.md b/packages/icons/README.md
index 6f336bc6e..9d568ead0 100644
--- a/packages/icons/README.md
+++ b/packages/icons/README.md
@@ -76,24 +76,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide-preact/README.md b/packages/lucide-preact/README.md
index 0ba6fc6b4..4b403cd45 100644
--- a/packages/lucide-preact/README.md
+++ b/packages/lucide-preact/README.md
@@ -67,24 +67,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide-react-native/README.md b/packages/lucide-react-native/README.md
index a4acea999..ddfe12cf3 100644
--- a/packages/lucide-react-native/README.md
+++ b/packages/lucide-react-native/README.md
@@ -67,24 +67,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide-react/README.md b/packages/lucide-react/README.md
index 09410e96f..c9c758e53 100644
--- a/packages/lucide-react/README.md
+++ b/packages/lucide-react/README.md
@@ -67,24 +67,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide-solid/README.md b/packages/lucide-solid/README.md
index 5ced5f68e..d9cfd9503 100644
--- a/packages/lucide-solid/README.md
+++ b/packages/lucide-solid/README.md
@@ -67,24 +67,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide-static/README.md b/packages/lucide-static/README.md
index 47f3233ce..d563b3d93 100644
--- a/packages/lucide-static/README.md
+++ b/packages/lucide-static/README.md
@@ -81,24 +81,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/lucide/README.md b/packages/lucide/README.md
index ca38e4352..13247ecee 100644
--- a/packages/lucide/README.md
+++ b/packages/lucide/README.md
@@ -77,24 +77,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/solid/README.md b/packages/solid/README.md
index 3094db0d9..3b36ce6fc 100644
--- a/packages/solid/README.md
+++ b/packages/solid/README.md
@@ -69,24 +69,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/svelte/README.md b/packages/svelte/README.md
index 7eb810548..d72981452 100644
--- a/packages/svelte/README.md
+++ b/packages/svelte/README.md
@@ -69,24 +69,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/packages/vue/README.md b/packages/vue/README.md
index 13ca60186..04ceec60a 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -67,24 +67,13 @@ Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/licen
-
-
[//]: <> (Sponsor tiers start)
-### Hero Backer ๐ฆธ
+### Bronze ๐ฅ
-
-
-
-### Awesome backers ๐บ
-
-
-
-
-### Backer โ๏ธ
-
-
-
+
+
+
[//]: <> (Sponsor tiers end)
diff --git a/scripts/updateSponsors.mts b/scripts/updateSponsors.mts
index 7cdb59fe7..180d19dd8 100644
--- a/scripts/updateSponsors.mts
+++ b/scripts/updateSponsors.mts
@@ -1,114 +1,39 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
-
-const COLLECTIVE_SLUG = 'lucide-icons';
-const OPEN_COLLECTIVE_API = 'https://api.opencollective.com/graphql/v2';
+import { TIERS, type TierId } from '../docs/.vitepress/theme/components/sponsor/tiers.ts';
+import {
+ type Account,
+ fetchCollective,
+ getCurrentSponsors,
+ getLocalLogos,
+ getOpenCollectiveLogo,
+ getWebsite,
+} from '../docs/scripts/helpers/openCollective.mts';
const rootPath = join(import.meta.dirname, '..');
const mainReadmePath = join(rootPath, 'README.md');
-const sponsorLogosPath = join(rootPath, 'docs/public/sponsors');
const tiersStartMarker = '[//]: <> (Sponsor tiers start)';
const tiersEndMarker = '[//]: <> (Sponsor tiers end)';
+const mainReadmeOnlyStartMarker = '[//]: <> (Main README only start)';
+const mainReadmeOnlyEndMarker = '[//]: <> (Main README only end)';
/**
- * Headings for the Open Collective tiers, keyed by tier slug.
- * Tiers that are not listed here fall back to their Open Collective name.
+ * How each sponsor tier shows up in the READMEs, following the tier perks in tiers.ts.
+ * Tiers that are not in the npm package READMEs are wrapped in the "Main README only" markers.
+ * Only tiers with `customLogo` use the logos in docs/public/sponsors, the others use their avatar.
*/
-const TIER_TITLES: Record = {
-'hero-backer': 'Hero Backer ๐ฆธ',
- 'awesome-backer': 'Awesome backers ๐บ',
- backer: 'Backer โ๏ธ',
+const README_TIERS: Record<
+ TierId,
+ { title: string; logoHeight: number; inPackages: boolean; customLogo: boolean }
+> = {
+ platinum: { title: 'Platinum ๐', logoHeight: 96, inPackages: true, customLogo: true },
+ gold: { title: 'Gold ๐ฅ', logoHeight: 64, inPackages: true, customLogo: true },
+ bronze: { title: 'Bronze ๐ฅ', logoHeight: 48, inPackages: true, customLogo: true },
+ backer: { title: 'Backers ๐บ', logoHeight: 32, inPackages: false, customLogo: false },
+ supporter: { title: 'Supporters โ๏ธ', logoHeight: 32, inPackages: false, customLogo: false },
};
-interface Account {
- slug: string;
- name: string;
- website: string | null;
- imageUrl: string | null;
- description: string | null;
-}
-
-interface Tier {
- slug: string;
- name: string;
- amount: { valueInCents: number | null } | null;
-}
-
-interface Order {
- tier: Pick | null;
- fromAccount: Account;
-}
-
-interface CollectiveResponse {
- data?: {
- account: {
- tiers: { nodes: Tier[] };
- orders: { nodes: Order[] };
- } | null;
- };
- errors?: { message: string }[];
-}
-
-const query = /* GraphQL */ `
- query Sponsors($slug: String!) {
- account(slug: $slug) {
- ... on AccountWithContributions {
- tiers(limit: 100) {
- nodes {
- slug
- name
- amount {
- valueInCents
- }
- }
- }
- }
- orders(filter: INCOMING, status: [ACTIVE], limit: 1000) {
- nodes {
- tier {
- slug
- }
- fromAccount {
- slug
- name
- website
- imageUrl
- description
- }
- }
- }
- }
- }
-`;
-
-async function fetchCollective() {
- const headers: Record = { 'Content-Type': 'application/json' };
- if (process.env.OPENCOLLECTIVE_TOKEN) {
- headers['Personal-Token'] = process.env.OPENCOLLECTIVE_TOKEN;
- }
-
- const response = await fetch(OPEN_COLLECTIVE_API, {
- method: 'POST',
- headers,
- body: JSON.stringify({ query, variables: { slug: COLLECTIVE_SLUG } }),
- });
-
- if (!response.ok) {
- throw new Error(`Open Collective API responded with ${response.status} ${response.statusText}`);
- }
-
- const { data, errors } = (await response.json()) as CollectiveResponse;
- if (errors?.length) {
- throw new Error(`Open Collective API errors: ${errors.map(({ message }) => message).join(', ')}`);
- }
- if (!data?.account) {
- throw new Error(`Could not find Open Collective account "${COLLECTIVE_SLUG}"`);
- }
-
- return data.account;
-}
-
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&')
@@ -116,48 +41,53 @@ const escapeHtml = (value: string) =>
.replaceAll('<', '<')
.replaceAll('>', '>');
-function getLogo({ slug, imageUrl }: Account) {
- const localLogo = ['svg', 'png'].find((extension) =>
- existsSync(join(sponsorLogosPath, `${slug}.${extension}`)),
- );
- if (localLogo) return `docs/public/sponsors/${slug}.${localLogo}`;
-
- return imageUrl ?? `https://images.opencollective.com/${slug}/logo/256.png`;
-}
-
-function renderSponsor(account: Account) {
- const href = account.website || `https://opencollective.com/${account.slug}`;
+/** Uses a `` to switch to the dark logo in dark mode, when there is one. */
+function renderSponsor(account: Account, height: number, customLogo: boolean) {
+ const logos: { light?: string; dark?: string } = customLogo ? getLocalLogos(account.slug) : {};
+ const src = logos.light ? `docs/public/sponsors/${logos.light}` : getOpenCollectiveLogo(account);
const alt = account.description ? `${account.name} โ ${account.description}` : account.name;
- return ``;
+ const image = ``;
+ const logo = logos.dark
+ ? `${image}`
+ : image;
+
+ return `${logo}`;
}
-function renderTiers({ tiers, orders }: Awaited>) {
- const sponsorsByTier = new Map>();
+function renderTiers({ orders }: Awaited>) {
+ const sponsorsByTier = Map.groupBy(getCurrentSponsors(orders.nodes), ({ tier }) => tier);
- orders.nodes.forEach(({ tier, fromAccount }) => {
- if (!tier) return;
- if (!sponsorsByTier.has(tier.slug)) sponsorsByTier.set(tier.slug, new Map());
- sponsorsByTier.get(tier.slug)!.set(fromAccount.slug, fromAccount);
- });
+ // Highest tier first, so Platinum sponsors are at the top.
+ const renderTierGroup = (inPackages: boolean) =>
+ [...TIERS]
+ .reverse()
+ .filter(({ id }) => README_TIERS[id].inPackages === inPackages)
+ .flatMap(({ id }) => {
+ const sponsors = sponsorsByTier.get(id) ?? [];
+ if (!sponsors.length) return [];
- return [...tiers.nodes]
- .sort((a, b) => (b.amount?.valueInCents ?? 0) - (a.amount?.valueInCents ?? 0))
- .flatMap((tier) => {
- const sponsors = [...(sponsorsByTier.get(tier.slug)?.values() ?? [])];
- if (!sponsors.length) return [];
+ const { title, logoHeight, customLogo } = README_TIERS[id];
+ const sponsorLinks = sponsors
+ .map(({ account }) => account)
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((account) => renderSponsor(account, logoHeight, customLogo))
+ // READMEs strip `style`, so non-breaking spaces are used to space out the logos.
+ .join('\n \n');
- if (!TIER_TITLES[tier.slug]) {
- console.warn(`No title configured for tier "${tier.slug}", using "${tier.name}"`);
- }
+ return `### ${title}\n\n${sponsorLinks}`;
+ })
+ .join('\n\n');
- const sponsorLinks = sponsors
- .sort((a, b) => a.name.localeCompare(b.name))
- .map(renderSponsor)
- .join('\n');
+ const packageTiers = renderTierGroup(true);
+ const mainReadmeOnlyTiers = renderTierGroup(false);
- return `### ${TIER_TITLES[tier.slug] ?? tier.name}\n\n${sponsorLinks}`;
- })
+ return [
+ packageTiers,
+ mainReadmeOnlyTiers &&
+ [mainReadmeOnlyStartMarker, mainReadmeOnlyTiers, mainReadmeOnlyEndMarker].join('\n\n'),
+ ]
+ .filter(Boolean)
.join('\n\n');
}
@@ -189,9 +119,13 @@ function updatePackageSponsors() {
const packagesPath = join(rootPath, 'packages');
const mainReadme = readFileSync(mainReadmePath, 'utf8');
const sponsorsRegex = /(\[\/\/]: <> \(Sponsors\).+)$/s;
+ const mainReadmeOnlyRegex =
+ /\[\/\/]: <> \(Main README only start\).*?\[\/\/]: <> \(Main README only end\)\s*/gs;
const sponsorsBlock = sponsorsRegex.exec(mainReadme);
if (sponsorsBlock?.[1]) {
- const packageSponsorsBlock = sponsorsBlock[1].replaceAll('src="docs/public', 'src="https://lucide.dev')
+ const packageSponsorsBlock = sponsorsBlock[1]
+ .replace(mainReadmeOnlyRegex, '')
+ .replaceAll('="docs/public/', '="https://lucide.dev/');
readdirSync(packagesPath).forEach((packagePath) => {
if (['lucide-figma', 'shared'].includes(packagePath) || packagePath.startsWith('.')) {
return;
@@ -203,14 +137,14 @@ function updatePackageSponsors() {
writeFileSync(readmePath, readmeContent.replace(sponsorsRegex, packageSponsorsBlock));
console.log(`Upgraded Sponsors block in ${readmePath}`);
} else {
- console.error(`Could not find Sponsors block in ${readmePath}`)
+ console.error(`Could not find Sponsors block in ${readmePath}`);
}
} else {
console.warn(`No readme found at ${readmePath}`);
}
- })
+ });
} else {
- console.error('Could not identify Sponsors block')
+ console.error('Could not identify Sponsors block');
}
}