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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/sponsors.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Sponsors

# Keeps docs/sponsors.json current so the website's sponsor wall features
# sponsors automatically — new sponsors appear within a day of sponsoring,
# lapsed ones roll off, no manual list edits.
# Keeps docs/sponsors.json and the README sponsor block current so the website's
# sponsor wall and the repo page feature sponsors automatically — new sponsors
# appear within a few hours of sponsoring, lapsed ones roll off, no manual list
# edits.
#
# The Sponsors GraphQL API rejects anonymous reads, so this runs here with a
# token. If the default Actions token can't read the org's sponsorships, add
Expand All @@ -11,12 +12,13 @@ name: Sponsors

on:
schedule:
- cron: '43 4 * * *' # daily, 04:43 UTC
- cron: '43 */3 * * *' # every 3 hours — a new sponsor shouldn't wait a day
workflow_dispatch: # refresh on demand
push:
branches: [main]
paths:
- 'scripts/sponsors.mjs'
- 'scripts/lib/sponsorsReadme.mjs'
- '.github/workflows/sponsors.yml'

permissions:
Expand Down Expand Up @@ -44,7 +46,7 @@ jobs:
run: |
# Stage first: on the very first run the file is untracked, and
# `git diff` alone would report no change and skip the seed commit.
git add -A docs/sponsors.json
git add -A docs/sponsors.json README.md
if git diff --cached --quiet; then
echo "No change."
exit 0
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,13 @@ Quickdraw is MIT-licensed with no paid tier — [sponsors](https://github.com/sp
are what keep it that way. Sponsors get their logo on
[the website](https://tryquickdraw.com/sponsors/) and here:

<a href="https://tryquickdraw.com/sponsors/"><i>No sponsors yet — your logo could be the first.</i></a>
<!-- sponsors:start -->
<table>
<tr>
<td align="center"><a href="https://tldraw.com"><img src="https://avatars.githubusercontent.com/u/86724562?v=4&amp;s=128" width="64" height="64" alt="tldraw"><br><sub><b>tldraw</b></sub></a></td>
</tr>
</table>
<!-- sponsors:end -->

## Star history

Expand Down
39 changes: 39 additions & 0 deletions scripts/lib/sponsorsReadme.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Renders the sponsor block that lives between the markers in README.md, so
// the repo page shows the same wall as the website. Pure string-in/string-out
// — scripts/sponsors.mjs does the file I/O.

export const START = '<!-- sponsors:start -->'
export const END = '<!-- sponsors:end -->'

const SPONSORS_PAGE = 'https://tryquickdraw.com/sponsors/'
const EMPTY = `<a href="${SPONSORS_PAGE}"><i>No sponsors yet — your logo could be the first.</i></a>`

const esc = (s) =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

// GitHub strips styles from README HTML, so layout is a plain table (the
// all-contributors trick): avatar on top, name under it, PER_ROW to a row.
const PER_ROW = 6

export function renderSponsorsBlock(sponsors) {
if (!sponsors.length) return EMPTY
const cells = sponsors
.slice()
.sort((a, b) => (b.monthly ?? 0) - (a.monthly ?? 0) || (a.since ?? '').localeCompare(b.since ?? ''))
.map((s) => {
const avatar = s.avatar + (s.avatar.includes('?') ? '&' : '?') + 's=128'
return `<td align="center"><a href="${esc(s.url)}"><img src="${esc(avatar)}" width="64" height="64" alt="${esc(s.name)}"><br><sub><b>${esc(s.name)}</b></sub></a></td>`
})
const rows = []
for (let i = 0; i < cells.length; i += PER_ROW) rows.push(`<tr>\n${cells.slice(i, i + PER_ROW).join('\n')}\n</tr>`)
return `<table>\n${rows.join('\n')}\n</table>`
}

// Returns the README with the block between the markers replaced, or null
// when the markers are missing — the caller decides how loud to be about it.
export function replaceSponsorsBlock(readme, sponsors) {
const a = readme.indexOf(START)
const b = readme.indexOf(END)
if (a === -1 || b === -1 || b < a) return null
return `${readme.slice(0, a + START.length)}\n${renderSponsorsBlock(sponsors)}\n${readme.slice(b)}`
}
43 changes: 35 additions & 8 deletions scripts/sponsors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// website's sponsor wall updates itself — nobody edits a list by hand when a
// sponsorship starts or lapses. Same shape as star-history: CI runs this on a
// schedule and commits the snapshot; the static build just reads the file.
// The same run rewrites the sponsor block between the markers in README.md,
// so the repo page never lags the website.
//
// Reading sponsorships needs an authenticated token. The Actions token can
// usually read an org's public sponsors; if GitHub rejects it, add a classic
Expand All @@ -11,12 +13,14 @@
//
// GITHUB_TOKEN=<token> node scripts/sponsors.mjs

import { writeFileSync, mkdirSync } from 'node:fs'
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { replaceSponsorsBlock } from './lib/sponsorsReadme.mjs'

const LOGIN = process.env.SPONSORS_LOGIN ?? 'quickdrawjs'
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const OUT = join(ROOT, 'docs')

const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
if (!token) {
Expand Down Expand Up @@ -88,9 +92,32 @@ const sponsors = (conn.nodes ?? [])
since: n.createdAt?.slice(0, 10) ?? null,
}))

mkdirSync(OUT, { recursive: true })
writeFileSync(
join(OUT, 'sponsors.json'),
JSON.stringify({ generatedAt: new Date().toISOString(), login: LOGIN, sponsors }, null, 2) + '\n',
)
console.log(`sponsors: wrote ${sponsors.length} sponsor(s) to docs/sponsors.json`)
// Only rewrite the snapshot when the wall actually changed — a fresh
// generatedAt alone would mean a commit (and a site deploy) on every run.
const snapshotPath = join(OUT, 'sponsors.json')
let previous = null
try {
previous = JSON.parse(readFileSync(snapshotPath, 'utf8')).sponsors
} catch {
// first run — no snapshot yet
}
if (JSON.stringify(previous) === JSON.stringify(sponsors)) {
console.log(`sponsors: ${sponsors.length} sponsor(s), unchanged`)
} else {
mkdirSync(OUT, { recursive: true })
writeFileSync(
snapshotPath,
JSON.stringify({ generatedAt: new Date().toISOString(), login: LOGIN, sponsors }, null, 2) + '\n',
)
console.log(`sponsors: wrote ${sponsors.length} sponsor(s) to docs/sponsors.json`)
}

const readmePath = join(ROOT, 'README.md')
const readme = readFileSync(readmePath, 'utf8')
const next = replaceSponsorsBlock(readme, sponsors)
if (next === null) {
console.warn('sponsors: README.md has no sponsors markers — left it alone')
} else if (next !== readme) {
writeFileSync(readmePath, next)
console.log('sponsors: updated the README sponsor block')
}
Loading