feat(docs): add sponsor page

Add a /sponsor page to lucide.dev with sponsor tiers, an amount picker,
a sponsor wall and a FAQ.

- Fetch active Open Collective sponsors at build time into
  sponsorsData.json, falling back to an empty list when the API fails
- Group sponsors by monthly amount so the wall keeps working while the
  Open Collective tiers are renamed
- Add reusable SponsorLogoGrid and SponsorAvatars components
- Add general Faq/FaqItem components fed by `faq` frontmatter, which
  also emits FAQPage structured data
- Link the page from the nav, footer and the home page sponsor card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric
2026-10-29 12:24:25 +01:00
co-authored by Claude Opus 5.5
parent bf346c16ee
commit 16c72ca3d9
25 changed files with 2195 additions and 2 deletions
+1
View File
@@ -66,6 +66,7 @@ docs/.vitepress/data/categoriesData.json
docs/.vitepress/data/iconDetails
docs/.vitepress/data/relatedIcons.json
docs/.vitepress/data/brandStopwords.json
docs/.vitepress/data/sponsorsData.json
docs/.vitepress/data/iconPopularity
docs/.vitepress/data/lab/iconNodes
docs/.vitepress/data/lab/iconMetaData.ts
+2
View File
@@ -88,6 +88,7 @@ export default defineConfig({
'index.md',
'packages.md',
'showcase.md',
'sponsor.md',
'brand-logo-statement.md',
'icons/**', // Not working, need investigation
],
@@ -117,6 +118,7 @@ export default defineConfig({
},
{ text: 'Packages', link: '/packages' },
{ text: 'Showcase', link: '/showcase' },
{ text: 'Sponsor', link: '/sponsor' },
{ text: 'Merch', link: 'https://merch.lucide.dev/' },
],
sidebar,
+20
View File
@@ -79,3 +79,23 @@ export default async function getStructuredData(iconName: string, pageData: Page
},
};
}
export interface FaqEntry {
question: string;
answer: string;
}
export function getFaqStructuredData(faq: FaqEntry[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faq.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: {
'@type': 'Answer',
text: answer,
},
})),
};
}
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useData } from 'vitepress';
import FaqItem from './FaqItem.vue';
interface FaqEntry {
question: string;
answer: string;
}
const props = withDefaults(
defineProps<{
title?: string;
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
/** Defaults to the page's `faq` frontmatter, which also feeds the FAQPage structured data. */
items?: FaqEntry[];
}>(),
{
headingLevel: 2,
},
);
const { frontmatter } = useData();
const entries = computed<FaqEntry[]>(() => props.items ?? frontmatter.value.faq ?? []);
const headingElement = computed(() => `h${props.headingLevel}`);
</script>
<template>
<section class="faq">
<component
v-if="title"
:is="headingElement"
class="title"
>
{{ title }}
</component>
<div class="items">
<FaqItem
v-for="entry in entries"
:key="entry.question"
:question="entry.question"
:answer="entry.answer"
/>
<slot />
</div>
</section>
</template>
<style scoped>
.faq {
display: flex;
flex-direction: column;
gap: 24px;
}
.title {
margin: 0;
font-size: 32px;
line-height: 40px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.items {
display: flex;
flex-direction: column;
border-top: 1px solid var(--vp-c-divider);
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { chevronDown } from '../../../data/iconNodes';
import Icon from '@lucide/vue/src/Icon';
defineProps<{
question: string;
answer?: string;
}>();
</script>
<template>
<details class="faq-item">
<summary class="question">
{{ question }}
<Icon
:iconNode="chevronDown"
name="chevron-down"
:size="20"
class="chevron"
aria-hidden="true"
/>
</summary>
<div class="answer">
<slot>
<p>{{ answer }}</p>
</slot>
</div>
</details>
</template>
<style scoped>
.faq-item {
border-bottom: 1px solid var(--vp-c-divider);
}
.question {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
font-size: 16px;
font-weight: 600;
color: var(--vp-c-text-1);
list-style: none;
cursor: pointer;
}
.question::-webkit-details-marker {
display: none;
}
.chevron {
flex-shrink: 0;
color: var(--vp-c-text-2);
transition: transform 0.2s;
}
.faq-item[open] .chevron {
transform: rotate(180deg);
}
.answer {
padding-bottom: 20px;
font-size: 16px;
line-height: 28px;
color: var(--vp-c-text-2);
}
.answer :slotted(p),
.answer p {
margin: 0;
}
</style>
@@ -22,7 +22,7 @@ import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vu
height="42"
/>
<VPButton
href="https://opencollective.com/lucide-icons"
href="/sponsor"
class="sponsor-button"
text="Become a sponsor"
/>
@@ -21,6 +21,10 @@ const links = computed(() => [
text: 'Contribute',
href: '/contribute/',
},
{
text: 'Sponsor',
href: '/sponsor',
},
{
text: 'Changelog',
href: `${githubLink.value}/releases`,
@@ -0,0 +1,456 @@
<script setup lang="ts">
import { computed, ref } 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 SponsorSection from './SponsorSection.vue';
import SponsorSectionHeader from './SponsorSectionHeader.vue';
import {
GITHUB_SPONSORS_URL,
OPEN_COLLECTIVE_URL,
TIERS,
formatEuro,
getPreviousTier,
getTierForAmount,
} from './tiers';
type Frequency = 'monthly' | 'once';
const frequencies: { id: Frequency; label: string }[] = [
{ id: 'monthly', label: 'Monthly' },
{ id: 'once', label: 'One-time' },
];
const frequency = ref<Frequency>('monthly');
const amount = ref(20);
// `v-model` on a number input casts to a number, an empty field stays ''.
const customAmount = ref<number | ''>('');
const isMonthly = computed(() => frequency.value === 'monthly');
const total = computed(() => {
const value = customAmount.value === '' ? amount.value : Number(customAmount.value);
return value > 0 ? value : 0;
});
const tier = computed(() => (isMonthly.value ? getTierForAmount(total.value) : null));
const amountText = computed(() => formatEuro(total.value));
const summary = computed(() => {
if (total.value === 0) {
return { title: 'Choose an amount', perks: 'Pick a preset or enter your own.' };
}
if (!isMonthly.value) {
return {
title: `${amountText.value} one-time`,
perks: 'A one-time thank-you. Tier perks apply to monthly sponsorships.',
};
}
if (tier.value) {
return {
title: `${amountText.value} / month · ${tier.value.name}`,
perks: tier.value.summary,
};
}
return { title: `${amountText.value} / month`, perks: 'Every bit helps. Thank you!' };
});
const perksPanel = computed(() => {
if (tier.value) {
const previousTier = getPreviousTier(tier.value.id);
return {
title: tier.value.name,
subtitle: `From ${formatEuro(tier.value.monthly)} / month`,
perks: [
...(previousTier ? [{ text: `Everything in ${previousTier.name}`, inherited: true }] : []),
...tier.value.perks.map((text) => ({ text, inherited: false })),
],
message: '',
};
}
if (!isMonthly.value) {
return {
title: 'One-time gift',
subtitle: total.value > 0 ? `${amountText.value}, once` : 'Any amount',
perks: [],
message:
'Thank you! Tier perks come with monthly sponsorships. Switch to Monthly to see what you would get.',
};
}
return {
title: 'No tier yet',
subtitle: total.value > 0 ? `${amountText.value} / month` : 'Choose an amount',
perks: [],
message: `Every bit helps. From ${formatEuro(TIERS[0].monthly)} a month you become a ${TIERS[0].name}.`,
};
});
const isSelected = (value: number) => customAmount.value === '' && amount.value === value;
function pickAmount(value: number) {
amount.value = value;
customAmount.value = '';
}
</script>
<template>
<SponsorSection band>
<SponsorSectionHeader
title="Choose your support"
description="Pick an amount, monthly or one-time. Monthly amounts map to a sponsor tier with its own perks. Lucide stays free and open source either way."
/>
<div class="layout">
<div class="card picker">
<div
role="group"
aria-label="Frequency"
class="frequency"
>
<button
v-for="option in frequencies"
:key="option.id"
type="button"
class="frequency-option"
:class="{ active: frequency === option.id }"
:aria-pressed="frequency === option.id"
@click="frequency = option.id"
>
{{ option.label }}
</button>
</div>
<div class="amounts">
<button
v-for="option in TIERS"
:key="option.id"
type="button"
class="amount"
:class="{ selected: isSelected(option.monthly) }"
:aria-pressed="isSelected(option.monthly)"
@click="pickAmount(option.monthly)"
>
<span class="amount-value">{{ formatEuro(option.monthly) }}</span>
<span class="amount-label">{{ isMonthly ? option.name : 'One-time' }}</span>
</button>
<label
class="amount custom"
:class="{ selected: customAmount !== '' }"
>
<span class="amount-label">Custom (€)</span>
<input
v-model="customAmount"
type="number"
min="1"
inputmode="numeric"
placeholder="Other"
class="amount-value custom-input"
/>
</label>
</div>
<div class="summary">
<div class="summary-text">
<span class="summary-title">{{ summary.title }}</span>
<span class="summary-perks">{{ summary.perks }}</span>
</div>
<div class="summary-actions">
<VPButton
:href="OPEN_COLLECTIVE_URL"
text="Open Collective"
/>
<VPButton
:href="GITHUB_SPONSORS_URL"
text="GitHub Sponsors"
theme="alt"
/>
</div>
</div>
</div>
<aside
class="card perks"
aria-live="polite"
>
<div class="perks-header">
<span class="eyebrow">Your perks</span>
<h3 class="perks-title">{{ perksPanel.title }}</h3>
<span class="perks-subtitle">{{ perksPanel.subtitle }}</span>
</div>
<ul
v-if="perksPanel.perks.length"
class="perks-list"
>
<li
v-for="perk in perksPanel.perks"
:key="perk.text"
class="perk"
:class="{ inherited: perk.inherited }"
>
<Icon
:iconNode="check"
name="check"
:size="18"
class="perk-icon"
aria-hidden="true"
/>
{{ perk.text }}
</li>
</ul>
<p
v-else
class="perks-message"
>
{{ perksPanel.message }}
</p>
<a
href="#tiers"
class="compare"
>
Compare all tiers
</a>
</aside>
</div>
</SponsorSection>
</template>
<style scoped>
.layout {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: stretch;
}
.card {
min-width: 0;
padding: 24px;
border-radius: 16px;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
gap: 24px;
}
.picker {
flex: 999 1 520px;
}
.frequency {
display: inline-flex;
align-self: flex-start;
padding: 4px;
gap: 4px;
border-radius: 999px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
}
.frequency-option {
min-height: 40px;
padding: 0 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-2);
transition:
color 0.25s,
background-color 0.25s;
}
.frequency-option.active {
background-color: var(--vp-c-bg);
color: var(--vp-c-text-1);
box-shadow: var(--vp-shadow-1);
}
.amounts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.amount {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
min-height: 72px;
padding: 8px 12px;
border-radius: 12px;
border: 2px solid var(--vp-c-divider);
background-color: var(--vp-c-bg);
color: var(--vp-c-text-1);
text-align: left;
cursor: pointer;
transition:
border-color 0.25s,
background-color 0.25s;
}
.amount:hover {
border-color: var(--vp-c-border);
}
.amount.selected {
border-color: var(--sponsor-accent);
background-color: rgba(245, 101, 101, 0.08);
}
.amount-value {
font-size: 20px;
line-height: 28px;
font-weight: 600;
}
.amount-label {
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.custom {
cursor: text;
}
.custom-input {
width: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--vp-c-text-1);
}
.custom:focus-within {
border-color: var(--sponsor-accent);
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 12px;
background-color: var(--vp-c-bg-soft);
}
.summary-text {
flex: 1 1 280px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-title {
font-size: 18px;
line-height: 24px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.summary-perks {
font-size: 14px;
line-height: 22px;
color: var(--vp-c-text-2);
}
.summary-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.perks {
flex: 1 1 300px;
gap: 20px;
}
.perks-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.eyebrow {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.perks-title {
margin: 0;
font-size: 24px;
line-height: 32px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.perks-subtitle {
font-size: 14px;
color: var(--vp-c-text-2);
}
.perks-list {
margin: 0;
padding: 16px 0 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
font-size: 14px;
line-height: 22px;
border-top: 1px solid var(--vp-c-divider);
}
.perk {
display: flex;
gap: 10px;
color: var(--vp-c-text-1);
}
.perk.inherited {
color: var(--vp-c-text-2);
font-weight: 500;
}
.perk-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--sponsor-accent);
}
.perks-message {
margin: 0;
padding-top: 16px;
border-top: 1px solid var(--vp-c-divider);
font-size: 14px;
line-height: 22px;
color: var(--vp-c-text-2);
}
.compare {
margin-top: auto;
padding: 10px 0;
font-size: 14px;
font-weight: 600;
color: var(--sponsor-accent);
text-decoration: none;
}
.compare:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getSupporters } from './sponsors';
import { OPEN_COLLECTIVE_URL } from './tiers';
const props = withDefaults(
defineProps<{
title?: string;
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Maximum number of avatars shown before "and N more". */
max?: number;
}>(),
{
title: 'Backers and supporters',
headingLevel: 3,
max: 48,
},
);
const supporters = getSupporters();
const visibleSupporters = computed(() => supporters.slice(0, props.max));
const remainingCount = computed(() => supporters.length - visibleSupporters.value.length);
const headingElement = computed(() => `h${props.headingLevel}`);
</script>
<template>
<div
v-if="supporters.length"
class="avatars"
>
<component
:is="headingElement"
class="title"
>
{{ title }}
</component>
<div class="list">
<a
v-for="supporter in visibleSupporters"
:key="supporter.slug"
:href="supporter.website"
:title="supporter.name"
target="_blank"
rel="noreferrer noopener"
class="avatar-link"
>
<img
:src="supporter.avatar"
:alt="supporter.name"
width="40"
height="40"
class="avatar"
loading="lazy"
/>
</a>
<a
v-if="remainingCount > 0"
:href="OPEN_COLLECTIVE_URL"
target="_blank"
rel="noreferrer"
class="more"
>
and {{ remainingCount }} more
</a>
</div>
</div>
</template>
<style scoped>
.avatars {
display: flex;
flex-direction: column;
gap: 12px;
}
.title {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.avatar-link {
display: block;
border-radius: 999px;
transition: transform 0.25s;
}
.avatar-link:hover {
transform: translateY(-2px);
}
.avatar {
width: 40px;
height: 40px;
border-radius: 999px;
object-fit: cover;
background-color: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-bg);
}
.more {
padding-left: 8px;
font-size: 14px;
color: var(--vp-c-text-2);
text-decoration: none;
}
.more:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
import SponsorSection from './SponsorSection.vue';
</script>
<template>
<SponsorSection class="cta">
<div class="cta-card">
<h2 class="title">Built something with Lucide?</h2>
<p class="description">
Help keep it beautiful and consistent for everyone who comes after you.
</p>
<div class="actions">
<VPButton
href="#tiers"
text="Become a sponsor"
size="big"
/>
<VPButton
href="https://github.com/lucide-icons/lucide"
text="Star on GitHub"
size="big"
theme="alt"
/>
</div>
</div>
</SponsorSection>
</template>
<style scoped>
.cta {
padding-top: 0;
padding-bottom: 96px;
}
.cta-card {
padding: 48px 32px;
border-radius: 16px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
}
.title {
margin: 0;
font-size: 32px;
line-height: 40px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.description {
margin: 0;
max-width: 560px;
font-size: 18px;
line-height: 28px;
color: var(--vp-c-text-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import Icon from '@lucide/vue/src/Icon';
import { gitPullRequest, server, shapes, users } from '../../../data/iconNodes';
import SponsorSection from './SponsorSection.vue';
import SponsorSectionHeader from './SponsorSectionHeader.vue';
const uses = [
{
title: 'Maintenance and reviews',
description: 'Reviewing icon PRs, fixing issues and shipping releases across every package.',
icon: gitPullRequest,
name: 'git-pull-request',
},
{
title: 'New icons and packages',
description: 'Designing icons the community asks for and supporting new frameworks.',
icon: shapes,
name: 'shapes',
},
{
title: 'Contributors',
description: 'Supporting the people who design, review and document Lucide.',
icon: users,
name: 'users',
},
{
title: 'Infrastructure',
description: 'Hosting lucide.dev, search, CI and the tools the team relies on.',
icon: server,
name: 'server',
},
];
</script>
<template>
<SponsorSection band>
<SponsorSectionHeader
title="Where your money goes"
description="Lucide holds its funds on Open Collective, so every expense is public."
/>
<div class="uses">
<div
v-for="use in uses"
:key="use.title"
class="use"
>
<div class="icon-tile">
<Icon
:iconNode="use.icon"
:name="use.name"
:size="22"
aria-hidden="true"
/>
</div>
<h3 class="use-title">{{ use.title }}</h3>
<p class="use-description">{{ use.description }}</p>
</div>
</div>
</SponsorSection>
</template>
<style scoped>
.uses {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.use {
padding: 24px;
border-radius: 12px;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
gap: 12px;
}
.icon-tile {
width: 44px;
height: 44px;
border-radius: 8px;
background-color: var(--vp-c-default-soft);
color: var(--sponsor-accent);
display: flex;
align-items: center;
justify-content: center;
}
.use-title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.use-description {
margin: 0;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,152 @@
<script setup lang="ts">
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';
const stats = [
...(data.stars != null
? [{ value: `${Math.floor(data.stars / 1000)}k+`, label: 'GitHub stars' }]
: []),
{
value: `${(Math.floor(data.iconsCount / 100) * 100).toLocaleString('en-US')}+`,
label: 'Icons',
},
{ value: sponsors.length.toLocaleString('en-US'), label: 'Sponsors' },
];
</script>
<template>
<SponsorSection class="hero">
<div class="hero-content">
<div class="intro">
<h1 class="title">Sponsor <span class="name">Lucide</span></h1>
<p class="lead">
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.
</p>
<div class="actions">
<VPButton
href="#tiers"
text="Become a sponsor"
size="big"
/>
<VPButton
href="#sponsors"
text="See our sponsors"
size="big"
theme="alt"
/>
</div>
</div>
<dl class="stats">
<div
v-for="stat in stats"
:key="stat.label"
class="stat"
>
<dt class="stat-label">{{ stat.label }}</dt>
<dd class="stat-value">{{ stat.value }}</dd>
</div>
</dl>
</div>
</SponsorSection>
</template>
<style scoped>
.hero {
padding-top: 96px;
}
.hero-content {
display: flex;
flex-wrap: wrap;
gap: 48px;
align-items: center;
}
.intro {
flex: 999 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 24px;
}
.title {
margin: 0;
font-size: 40px;
line-height: 48px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.name {
color: var(--vp-c-brand-3);
}
.lead {
margin: 0;
max-width: 560px;
font-size: 18px;
line-height: 28px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stats {
flex: 1 1 340px;
min-width: 0;
margin: 0;
padding: 24px;
border-radius: 16px;
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;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-value {
order: -1;
margin: 0;
font-size: 24px;
line-height: 32px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.stat-label {
font-size: 13px;
color: var(--vp-c-text-2);
white-space: nowrap;
}
@media (min-width: 768px) {
.title {
font-size: 56px;
line-height: 64px;
}
.stat-value {
font-size: 28px;
line-height: 36px;
}
}
</style>
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getSponsorsByTier } from './sponsors';
import { getTier } from './tiers';
type LogoTier = 'platinum' | 'gold' | 'bronze';
type Size = 'lg' | 'md' | 'sm';
const defaultSizes: Record<LogoTier, Size> = {
platinum: 'lg',
gold: 'md',
bronze: 'sm',
};
const props = withDefaults(
defineProps<{
tier: LogoTier;
/** Defaults to the tier name. */
title?: string;
headingLevel?: 2 | 3 | 4 | 5 | 6;
size?: Size;
/**
* Number of "Your logo here" slots to fill up to. Without placeholders the grid
* renders nothing while the tier has no sponsors.
*/
placeholders?: number;
}>(),
{
headingLevel: 3,
placeholders: 0,
},
);
const sponsors = getSponsorsByTier(props.tier);
const placeholderCount = computed(() => Math.max(props.placeholders - sponsors.length, 0));
const headingElement = computed(() => `h${props.headingLevel}`);
const sizeClass = computed(() => props.size ?? defaultSizes[props.tier]);
</script>
<template>
<div
v-if="sponsors.length || placeholderCount"
class="logo-grid"
>
<component
:is="headingElement"
class="title"
>
{{ title ?? getTier(tier).name }}
</component>
<div
class="tiles"
:class="sizeClass"
>
<a
v-for="sponsor in sponsors"
:key="sponsor.slug"
:href="sponsor.website"
target="_blank"
rel="noreferrer noopener"
class="tile"
>
<img
:src="sponsor.image"
:alt="sponsor.name"
class="logo"
loading="lazy"
/>
</a>
<a
v-for="index in placeholderCount"
:key="`placeholder-${index}`"
href="/sponsor#tiers"
class="tile placeholder"
>
Your logo here
</a>
</div>
</div>
</template>
<style scoped>
.logo-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.title {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tiles {
display: grid;
gap: 12px;
}
.tiles.lg {
grid-template-columns: 1fr;
}
.tiles.md {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.tiles.sm {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.tile {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 24px;
border-radius: 12px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
text-decoration: none;
transition: border-color 0.25s;
}
.tile:hover {
border-color: var(--vp-c-brand-1);
}
.lg .tile {
min-height: 120px;
}
.md .tile {
min-height: 96px;
}
.sm .tile {
min-height: 72px;
}
.logo {
max-width: 70%;
object-fit: contain;
}
.lg .logo {
max-height: 72px;
}
.md .logo {
max-height: 56px;
}
.sm .logo {
max-height: 40px;
}
.placeholder {
background-color: transparent;
border: 2px dashed var(--vp-c-border);
color: var(--vp-c-text-2);
font-size: 15px;
font-weight: 600;
}
.lg .placeholder {
font-size: 16px;
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{
/** Full-bleed tinted background with divider borders. */
band?: boolean;
narrow?: boolean;
}>();
</script>
<template>
<section
class="sponsor-section"
:class="{ band }"
>
<div
class="inner"
:class="{ narrow }"
>
<slot />
</div>
</section>
</template>
<style scoped>
.sponsor-section {
--sponsor-accent: var(--vp-c-brand-5);
padding: 72px 24px;
}
html.dark .sponsor-section {
--sponsor-accent: var(--vp-c-brand-1);
}
.sponsor-section.band {
background-color: var(--vp-c-bg-soft);
border-top: 1px solid var(--vp-c-divider);
border-bottom: 1px solid var(--vp-c-divider);
}
.inner {
max-width: 1152px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
}
.inner.narrow {
max-width: 768px;
gap: 24px;
}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
}>();
</script>
<template>
<div class="section-header">
<h2 class="title">{{ title }}</h2>
<p
v-if="description"
class="description"
>
{{ description }}
</p>
</div>
</template>
<style scoped>
.section-header {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 680px;
}
.title {
margin: 0;
font-size: 32px;
line-height: 40px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.description {
margin: 0;
font-size: 16px;
line-height: 28px;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,28 @@
import iconNodes from '../../../data/iconNodes';
async function fetchStars() {
try {
const response = await fetch('https://api.github.com/repos/lucide-icons/lucide');
if (!response.ok) return null;
const { stargazers_count } = (await response.json()) as { stargazers_count: number };
return stargazers_count;
} catch {
return null;
}
}
const loader = {
async load() {
return {
stars: await fetchStars(),
iconsCount: Object.keys(iconNodes).length,
};
},
};
declare const data: Awaited<ReturnType<typeof loader.load>>;
export { data };
export default loader;
@@ -0,0 +1,193 @@
<script setup lang="ts">
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';
const props = defineProps<{
tier: Tier;
}>();
const previousTier = computed(() => getPreviousTier(props.tier.id));
</script>
<template>
<article class="tier-card">
<div class="header">
<h3 class="name">{{ tier.name }}</h3>
<div class="price">
<span class="amount">{{ formatEuro(tier.monthly) }}</span>
<span class="period">/ month</span>
</div>
<span
v-if="tier.yearly"
class="yearly"
>
or {{ formatEuro(tier.yearly) }} / year, two months free
</span>
</div>
<ul class="perks">
<li
v-if="previousTier"
class="inherited"
>
Everything in {{ previousTier.name }}, plus
</li>
<li
v-for="perk in tier.perks"
:key="perk"
class="perk"
>
<Icon
:iconNode="check"
name="check"
:size="18"
class="perk-icon"
aria-hidden="true"
/>
{{ perk }}
</li>
</ul>
<div class="actions">
<VPButton
:href="OPEN_COLLECTIVE_URL"
text="Open Collective"
:theme="tier.featured ? 'brand' : 'alt'"
class="primary-action"
/>
<a
:href="GITHUB_SPONSORS_URL"
target="_blank"
rel="noreferrer"
class="secondary-action"
>
GitHub Sponsors
</a>
</div>
</article>
</template>
<style scoped>
.tier-card {
flex: 0 0 300px;
scroll-snap-align: start;
padding: 24px;
border-radius: 12px;
border: 1px solid var(--vp-c-divider);
background-color: var(--vp-c-bg);
display: flex;
flex-direction: column;
gap: 16px;
transition: border-color 0.25s;
}
.tier-card:last-child {
scroll-snap-align: end;
}
.tier-card:hover,
.tier-card:focus-within {
border-color: var(--vp-c-brand-3);
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
margin: 0;
font-size: 20px;
line-height: 28px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.price {
display: flex;
align-items: baseline;
gap: 4px;
}
.amount {
font-size: 32px;
line-height: 40px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
}
.period,
.yearly {
font-size: 14px;
color: var(--vp-c-text-2);
}
.yearly {
font-size: 13px;
}
.perks {
flex-grow: 1;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14px;
line-height: 22px;
color: var(--vp-c-text-1);
}
.inherited {
color: var(--vp-c-text-2);
font-weight: 500;
}
.perk {
display: flex;
gap: 8px;
}
.perk-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--sponsor-accent);
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.primary-action {
display: block;
}
.secondary-action {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
font-size: 14px;
font-weight: 600;
color: var(--sponsor-accent);
text-decoration: none;
}
.secondary-action:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useScroll } from '@vueuse/core';
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
import Icon from '@lucide/vue/src/Icon';
import { chevronLeft, chevronRight } from '../../../data/iconNodes';
import SponsorSection from './SponsorSection.vue';
import SponsorSectionHeader from './SponsorSectionHeader.vue';
import SponsorTierCard from './SponsorTierCard.vue';
import { PARTNER_EMAIL, TIERS } from './tiers';
// Card width plus the gap between cards.
const SCROLL_STEP = 316;
const track = ref<HTMLElement | null>(null);
const { arrivedState } = useScroll(track);
function scrollTrack(direction: 1 | -1) {
track.value?.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' });
}
</script>
<template>
<SponsorSection
id="tiers"
class="tiers"
>
<div class="tiers-header">
<SponsorSectionHeader
title="Sponsor tiers"
description="Two tiers for individuals, three for companies. Each step adds one clear placement, and perks stack as you go up. Same tiers on Open Collective (EUR) and GitHub Sponsors (USD)."
class="tiers-title"
/>
<div class="track-controls">
<button
type="button"
class="track-button"
aria-label="Previous tiers"
:disabled="arrivedState.left"
@click="scrollTrack(-1)"
>
<Icon
:iconNode="chevronLeft"
name="chevron-left"
:size="20"
aria-hidden="true"
/>
</button>
<button
type="button"
class="track-button"
aria-label="Next tiers"
:disabled="arrivedState.right"
@click="scrollTrack(1)"
>
<Icon
:iconNode="chevronRight"
name="chevron-right"
:size="20"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="track-wrapper">
<div
ref="track"
class="track"
role="region"
aria-label="Sponsor tiers"
tabindex="0"
>
<SponsorTierCard
v-for="tier in TIERS"
:key="tier.id"
:tier="tier"
/>
</div>
<div
class="fade fade-left"
:class="{ visible: !arrivedState.left }"
aria-hidden="true"
/>
<div
class="fade fade-right"
:class="{ visible: !arrivedState.right }"
aria-hidden="true"
/>
</div>
<div class="partner">
<div class="partner-text">
<h3 class="partner-title">Partner? Let's talk.</h3>
<p class="partner-description">
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.
</p>
</div>
<VPButton
:href="`mailto:${PARTNER_EMAIL}`"
text="Contact us"
theme="alt"
target="_self"
/>
</div>
<p class="disclaimer">
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.
</p>
</SponsorSection>
</template>
<style scoped>
.tiers {
padding-top: 96px;
padding-bottom: 48px;
/* The track bleeds to the right edge of the viewport. */
overflow-x: clip;
}
.tiers-header {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
align-items: flex-end;
justify-content: space-between;
}
.tiers-title {
flex: 999 1 480px;
min-width: 0;
}
.track-controls {
display: flex;
gap: 8px;
}
.track-button {
width: 44px;
height: 44px;
border-radius: 999px;
border: 1px solid var(--vp-c-border);
background-color: var(--vp-c-bg);
color: var(--vp-c-text-1);
display: flex;
align-items: center;
justify-content: center;
transition:
background-color 0.25s,
opacity 0.25s;
}
.track-button:not(:disabled):hover {
background-color: var(--vp-c-bg-soft);
}
.track-button:disabled {
opacity: 0.4;
cursor: default;
}
.track-wrapper {
position: relative;
margin-right: min(-24px, calc((1152px - 100vw) / 2));
}
.track {
display: flex;
gap: 16px;
align-items: stretch;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
padding-bottom: 12px;
padding-right: 24px;
scroll-padding-right: 24px;
scrollbar-width: thin;
}
.track:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 4px;
border-radius: 12px;
}
.fade {
position: absolute;
top: 0;
bottom: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.fade.visible {
opacity: 1;
}
.fade-left {
left: 0;
width: 96px;
background: linear-gradient(to left, transparent, var(--vp-c-bg));
}
.fade-right {
right: 0;
width: 160px;
background: linear-gradient(to right, transparent, var(--vp-c-bg) 85%);
}
.partner {
padding: 24px 28px;
border-radius: 16px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: center;
justify-content: space-between;
}
.partner-text {
flex: 999 1 480px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.partner-title {
margin: 0;
font-size: 20px;
line-height: 28px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.partner-description,
.disclaimer {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--vp-c-text-2);
}
.disclaimer {
margin-top: -8px;
font-size: 13px;
line-height: 20px;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import SponsorSection from './SponsorSection.vue';
import SponsorSectionHeader from './SponsorSectionHeader.vue';
import SponsorLogoGrid from './SponsorLogoGrid.vue';
import SponsorAvatars from './SponsorAvatars.vue';
</script>
<template>
<SponsorSection
id="sponsors"
class="wall"
>
<SponsorSectionHeader
title="Our sponsors"
description="Thank you to everyone who keeps Lucide going."
class="wall-header"
/>
<SponsorLogoGrid
tier="platinum"
:placeholders="1"
/>
<SponsorLogoGrid
tier="gold"
:placeholders="2"
/>
<SponsorLogoGrid tier="bronze" />
<SponsorAvatars />
</SponsorSection>
</template>
<style scoped>
.wall {
padding-top: 48px;
padding-bottom: 96px;
}
.wall-header {
max-width: none;
padding-top: 24px;
border-top: 1px solid var(--vp-c-divider);
}
</style>
@@ -0,0 +1,20 @@
import sponsorsData from '../../../data/sponsorsData.json';
import { getTier, getTierForAmount, type TierId } from './tiers';
export interface Sponsor {
slug: string;
name: string;
website: string;
image: string;
avatar: string;
monthlyAmount: number;
}
export const sponsors: Sponsor[] = sponsorsData.sponsors;
export const getSponsorsByTier = (tierId: TierId) =>
sponsors.filter(({ monthlyAmount }) => getTierForAmount(monthlyAmount)?.id === tierId);
/** Everyone below the first logo tier, shown as avatars. */
export const getSupporters = () =>
sponsors.filter(({ monthlyAmount }) => monthlyAmount < getTier('bronze').monthly);
@@ -0,0 +1,92 @@
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;
}
export const TIERS: Tier[] = [
{
id: 'supporter',
name: 'Supporter',
monthly: 5,
audience: 'For individuals',
summary: 'Sponsor badge and a Discord sponsor role.',
perks: ['Sponsor badge', 'Discord sponsor role'],
},
{
id: 'backer',
name: 'Backer',
monthly: 20,
audience: 'For individuals and freelancers',
summary: 'Name and avatar on lucide.dev/sponsor, plus your name in the README.',
perks: ['Name and avatar on lucide.dev/sponsor', 'Name in the README'],
},
{
id: 'bronze',
name: 'Bronze',
monthly: 100,
audience: 'For small companies and startups',
summary: 'Small logo on lucide.dev/sponsor and in npm READMEs, plus a sticker sheet.',
perks: [
'Small logo on lucide.dev/sponsor',
'Small logo in the README and npm package READMEs',
'Lucide sticker sheet',
],
},
{
id: 'gold',
name: 'Gold',
monthly: 500,
yearly: 5000,
audience: 'For companies shipping Lucide',
summary: 'Large logo on the lucide.dev homepage and docs sidebar, plus a welcome t-shirt.',
perks: [
'Large logo on the lucide.dev homepage and docs sidebar',
'Medium logo in npm package READMEs',
'One-time welcome t-shirt',
],
featured: true,
},
{
id: 'platinum',
name: 'Platinum',
monthly: 1000,
yearly: 10000,
audience: 'For large companies and dev tools',
summary: 'Top homepage placement, logo on icon search and a private maintainer channel.',
perks: [
'Top placement on the homepage',
'Logo on the icon search page',
'Large logo in the top row of npm READMEs',
'Dedicated welcome post',
'Private channel with maintainers',
],
},
];
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;
}
export const formatEuro = (amount: number) => `€${amount.toLocaleString('en-US')}`;
+10 -1
View File
@@ -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);
+1
View File
@@ -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",
+161
View File
@@ -0,0 +1,161 @@
import fs from 'fs';
import path from 'path';
import { loadEnvFile } from 'node:process';
const COLLECTIVE_SLUG = 'lucide-icons';
const OPEN_COLLECTIVE_API = 'https://api.opencollective.com/graphql/v2';
const currentDir = process.cwd();
const dataDirectory = path.resolve(currentDir, '.vitepress/data');
const sponsorLogosPath = path.resolve(currentDir, 'public/sponsors');
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.
}
interface Account {
slug: string;
name: string;
website: string | null;
imageUrl: string | null;
}
interface Order {
amount: { valueInCents: number | null } | null;
frequency: 'MONTHLY' | 'YEARLY' | 'ONETIME' | null;
fromAccount: Account;
}
interface CollectiveResponse {
data?: {
account: {
orders: { nodes: Order[] };
} | null;
};
errors?: { message: string }[];
}
export interface Sponsor {
slug: string;
name: string;
website: string;
/** Logo for the logo tiers, preferring a local file in `public/sponsors`. */
image: 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;
}
const query = /* GraphQL */ `
query Sponsors($slug: String!) {
account(slug: $slug) {
orders(filter: INCOMING, status: [ACTIVE], limit: 1000) {
nodes {
amount {
valueInCents
}
frequency
fromAccount {
slug
name
website
imageUrl
}
}
}
}
}
`;
async function fetchOrders() {
const headers: Record<string, string> = { '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.orders.nodes;
}
function getLogo({ slug, imageUrl }: Account) {
const localLogo = ['svg', 'png'].find((extension) =>
fs.existsSync(path.join(sponsorLogosPath, `${slug}.${extension}`)),
);
if (localLogo) return `/sponsors/${slug}.${localLogo}`;
return imageUrl ?? `https://images.opencollective.com/${slug}/logo/256.png`;
}
function getMonthlyAmount({ amount, frequency }: Order) {
const value = (amount?.valueInCents ?? 0) / 100;
return frequency === 'YEARLY' ? value / 12 : value;
}
function toSponsors(orders: Order[]) {
const sponsors = new Map<string, Sponsor>();
orders.forEach((order) => {
const { fromAccount } = order;
const existing = sponsors.get(fromAccount.slug);
if (existing) {
existing.monthlyAmount += getMonthlyAmount(order);
return;
}
sponsors.set(fromAccount.slug, {
slug: fromAccount.slug,
name: fromAccount.name,
website: fromAccount.website || `https://opencollective.com/${fromAccount.slug}`,
image: getLogo(fromAccount),
avatar:
fromAccount.imageUrl ??
`https://images.opencollective.com/${fromAccount.slug}/avatar/128.png`,
monthlyAmount: getMonthlyAmount(order),
});
});
return [...sponsors.values()]
.map((sponsor) => ({
...sponsor,
monthlyAmount: Math.round(sponsor.monthlyAmount * 100) / 100,
}))
.sort((a, b) => b.monthlyAmount - a.monthlyAmount || a.name.localeCompare(b.name));
}
let sponsors: Sponsor[] = [];
try {
sponsors = toSponsors(await fetchOrders());
} 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');
console.log(`Wrote ${sponsors.length} sponsors to ${outputPath}`);
+45
View File
@@ -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.
---
<script setup>
import Faq from '~/.vitepress/theme/components/base/Faq.vue'
import SponsorSection from '~/.vitepress/theme/components/sponsor/SponsorSection.vue'
import SponsorHero from '~/.vitepress/theme/components/sponsor/SponsorHero.vue'
import SponsorAmountPicker from '~/.vitepress/theme/components/sponsor/SponsorAmountPicker.vue'
import SponsorTiers from '~/.vitepress/theme/components/sponsor/SponsorTiers.vue'
import SponsorWall from '~/.vitepress/theme/components/sponsor/SponsorWall.vue'
import SponsorFundingUses from '~/.vitepress/theme/components/sponsor/SponsorFundingUses.vue'
import SponsorCta from '~/.vitepress/theme/components/sponsor/SponsorCta.vue'
</script>
<SponsorHero />
<SponsorAmountPicker />
<SponsorTiers />
<SponsorWall />
<SponsorFundingUses />
<SponsorSection narrow class="faq-section">
<Faq title="Frequently asked questions" />
</SponsorSection>
<SponsorCta />
<style scoped>
.faq-section {
padding-block: 96px;
}
</style>