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
75 changes: 75 additions & 0 deletions .claude/skills/sponsors/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: sponsors
description: >
Keeps resources/data/sponsors.json in step with GitHub Sponsors: who sponsors TablePro now, where
each one is placed, the open homepage places and the tier prices. Use when the user says "check
sponsors", "sponsor drift", "new sponsor", "add <company> as a sponsor", "a sponsor cancelled",
"who sponsors us", or after changing the tiers on GitHub. Read-only against GitHub.
---

# Sponsors

`resources/data/sponsors.json` is the only list the site reads: the homepage strip, `/sponsors`
and the open-place count all come from it. Nothing in the app calls GitHub for sponsors, because
GitHub's sponsorship API needs credentials and this repository holds none. This skill is the check.

Commands run from this repository's root.

## 1. Report

```bash
python3 .claude/skills/sponsors/scripts/drift.py
```

It runs `gh api graphql` with the maintainer's own `gh` login, for the user in
`facts.json` → `links.sponsorsProgram` and the TableProApp organization. If `gh` reports a missing
scope, the user runs `gh auth refresh -h github.com -s read:user` themselves.

| Code | Meaning | Fix |
|---|---|---|
| MISSING | A public sponsor whose amount earns a placement is not listed | Add it (step 2) |
| GONE | A listed sponsor has no current public sponsorship | Remove the entry and its logo file |
| PLACEMENT | Listed above or below what the amount earns | Change `placement` |
| PRIVATE | A private sponsor is listed | Remove it: private sponsors are never named |
| TIERS | Prices on GitHub and in `tiers` differ | Change whichever side is wrong, then rerun |
| STALE | `verifiedAt` is over 30 days old | Rerun and set it once clean |
| LEGACY | Info: kept from before the current tiers (`TIERS_SINCE` in the script) | Nothing |
| EXPIRES | Info: a one-time placement ends within 30 days | Remove it after that date |

A one-time sponsorship counts for `oneTime.months` from its date, at `multiplier` times the
monthly price. GitHub drops one-time sponsors from its own list after a month, so only the script
tracks them.

## 2. Change the data

- One object per sponsor: `id` (kebab-case), `name` as the sponsor writes it, `githubLogin`,
`placement` (`lead`, `homepage` or `name`), `url` with `?ref=tablepro` where the site
takes one, and `logo` (`null` for `name`).
- Display order is the array order; a `lead` is drawn first wherever it sits.
- Logos: the file the sponsor sent, at `public/images/sponsors/{id}.svg|png|webp`, with its real
`width` and `height` (an SVG needs both attributes on its root). A dark-mode file is
`{id}-dark.*` with the same shape; without one the logo sits on a white tile. Never recolour or
filter a logo. Check a third-party SVG holds no script, link or embedded image.
- Set `verifiedAt` to the day of a clean report.

Never write an amount, a tier per sponsor, a private sponsor or the report itself into the
repository: it is public. The report stays in the session.

## 3. The README

TablePro's README (TableProApp/TablePro) shows the same sponsors from `.github/assets/sponsors/`.
Regenerate those files from `public/images/sponsors` and update the README's Sponsors section in a
PR there, after this one deploys:

```bash
python3 .claude/skills/sponsors/scripts/readme_tiles.py ../TablePro/.github/assets/sponsors
```

## 4. Ship

```bash
php artisan test --compact --filter='Sponsors|HomepageRender'
npm run test:js
```

Commit as `chore(sponsors): ...` on a branch and open a PR. `main` deploys itself.
182 changes: 182 additions & 0 deletions .claude/skills/sponsors/scripts/drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# Compares GitHub Sponsors with resources/data/sponsors.json and prints what differs.
# Read-only: it runs queries through `gh`, which holds the credentials, and writes nothing.
import argparse
import json
import subprocess
import sys
from datetime import date, datetime, timedelta
from pathlib import Path

WEB_ROOT = Path(__file__).resolve().parents[4]
DATA = WEB_ROOT / "resources" / "data" / "sponsors.json"
FACTS = WEB_ROOT / "resources" / "data" / "facts.json"

# Sponsorships started or last changed before the current tiers went live keep the placement they had.
TIERS_SINCE = date(2026, 10, 29)
STALE_DAYS = 30
EXPIRY_WARNING_DAYS = 30

SPONSORSHIPS = """
query($login: String!, $endCursor: String) {
%s(login: $login) {
sponsorshipsAsMaintainer(first: 100, after: $endCursor, activeOnly: false, includePrivate: true) {
pageInfo { hasNextPage endCursor }
nodes {
isActive isOneTimePayment privacyLevel createdAt tierSelectedAt
tier { monthlyPriceInDollars isOneTime isCustomAmount }
sponsorEntity { __typename ... on User { login name } ... on Organization { login name } }
}
}
}
}
"""

TIERS = """
query($login: String!) {
user(login: $login) {
sponsorsListing { tiers(first: 50) { nodes { monthlyPriceInDollars isOneTime isCustomAmount } } }
}
}
"""


def gh(query, **variables):
args = ["gh", "api", "graphql", "-f", "query=" + query]
for key, value in variables.items():
if value is not None:
args += ["-F", f"{key}={value}"]
result = subprocess.run(args, capture_output=True, text=True)
if result.returncode != 0:
sys.exit("gh api graphql failed. If it names a missing scope, run `gh auth refresh -h github.com -s read:user`.\n" + result.stderr.strip())
return json.loads(result.stdout)["data"]


def sponsorships(kind, login):
nodes, cursor = [], None
while True:
page = gh(SPONSORSHIPS % kind, login=login, endCursor=cursor)[kind]["sponsorshipsAsMaintainer"]
nodes += page["nodes"]
if not page["pageInfo"]["hasNextPage"]:
return nodes
cursor = page["pageInfo"]["endCursor"]


def day(stamp):
return datetime.fromisoformat(stamp.replace("Z", "+00:00")).date()


def rank(placement, data):
order = [tier["placement"] for tier in data["tiers"]]
return -1 if placement is None else order.index(placement)


def earned(amount, one_time, data):
multiplier = data["oneTime"]["multiplier"] if one_time else 1
placement = None
for tier in data["tiers"]:
if amount >= tier["monthly"] * multiplier:
placement = tier["placement"]
return placement


def price_label(amount, one_time):
return f"${amount}" + (" one time" if one_time else " a month")


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--today", type=date.fromisoformat, default=date.today())
args = parser.parse_args()
today = args.today

data = json.loads(DATA.read_text())
facts = json.loads(FACTS.read_text())
program = facts["links"]["sponsorsProgram"]
maintainer = program.rstrip("/").rsplit("/", 1)[-1]
months = data["oneTime"]["months"]

nodes = sponsorships("user", maintainer) + sponsorships("organization", "TableProApp")
listed = {sponsor["githubLogin"].lower(): sponsor for sponsor in data["sponsors"]}

drift = 0

def report(code, text, counts=True):
nonlocal drift
drift += counts
print(f"{code:<10} {text}")

current, private = {}, 0
for node in nodes:
entity = node["sponsorEntity"]
if entity is None or node["tier"] is None:
continue
started = day(node["createdAt"])
changed = day(node["tierSelectedAt"] or node["createdAt"])
one_time = node["isOneTimePayment"]
ends = started + timedelta(days=round(months * 365 / 12)) if one_time else None
live = (ends is not None and ends > today) if one_time else node["isActive"]
if not live:
continue
if node["privacyLevel"] != "PUBLIC":
private += 1
if entity["login"].lower() in listed:
report("PRIVATE", f"{entity['login']} sponsors privately but is listed: remove it")
continue
sponsor = {
"login": entity["login"],
"name": entity.get("name") or entity["login"],
"kind": entity["__typename"],
"amount": node["tier"]["monthlyPriceInDollars"],
"oneTime": one_time,
"ends": ends,
"legacy": changed < TIERS_SINCE,
"placement": earned(node["tier"]["monthlyPriceInDollars"], one_time, data),
}
# One login can hold a monthly and a one-time sponsorship at once: the better placement counts.
known = current.get(entity["login"].lower())
if known is None or rank(sponsor["placement"], data) > rank(known["placement"], data):
current[entity["login"].lower()] = sponsor

for login, sponsor in sorted(current.items()):
price = price_label(sponsor["amount"], sponsor["oneTime"])
entry = listed.get(login)
if entry is None:
if sponsor["placement"] is not None:
report("MISSING", f"{sponsor['login']} ({sponsor['name']}, {sponsor['kind']}, {price}) earns placement '{sponsor['placement']}'")
continue
if sponsor["placement"] != entry["placement"]:
earns = sponsor["placement"] or "no placement"
if sponsor["legacy"]:
report("LEGACY", f"{sponsor['login']} ({price}) keeps '{entry['placement']}' from before {TIERS_SINCE}", counts=False)
else:
report("PLACEMENT", f"{sponsor['login']} ({price}) is listed as '{entry['placement']}' but earns {earns}")
if sponsor["ends"] is not None and (sponsor["ends"] - today).days <= EXPIRY_WARNING_DAYS:
report("EXPIRES", f"{sponsor['login']}'s one-time placement ends on {sponsor['ends']}", counts=False)

for login, entry in sorted(listed.items()):
if login not in current:
report("GONE", f"{entry['githubLogin']} ({entry['name']}) has no current public sponsorship: remove it and its logo")

tiers = gh(TIERS, login=maintainer)["user"]["sponsorsListing"]["tiers"]["nodes"]
published = {(tier["monthlyPriceInDollars"], tier["isOneTime"]) for tier in tiers if not tier["isCustomAmount"]}
wanted = {(tier["monthly"], False) for tier in data["tiers"]}
wanted |= {(tier["monthly"] * data["oneTime"]["multiplier"], True) for tier in data["tiers"]}
for amount, one_time in sorted(wanted - published):
report("TIERS", f"{price_label(amount, one_time)} is in sponsors.json but not published on GitHub")
for amount, one_time in sorted(published - wanted):
report("TIERS", f"{price_label(amount, one_time)} is published on GitHub but not in sponsors.json")

on_homepage = sum(entry["placement"] in ("lead", "homepage") for entry in data["sponsors"])
age = (today - date.fromisoformat(data["verifiedAt"])).days
if age > STALE_DAYS:
report("STALE", f"verifiedAt is {age} days old")

print(f"\n{len(current)} current public sponsors, {private} private (never listed), "
f"{max(0, data['homepageSlots'] - on_homepage)} of {data['homepageSlots']} homepage places open.")
print("No drift." if drift == 0 else f"{drift} to fix.")
return 1 if drift else 0


if __name__ == "__main__":
sys.exit(main())
50 changes: 50 additions & 0 deletions .claude/skills/sponsors/scripts/readme_tiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# Writes the README's sponsor logos, each on a white tile so a dark logo stays visible in GitHub's dark theme.
# Usage: readme_tiles.py [path to TablePro's .github/assets/sponsors]
import base64
import json
import re
import sys
from pathlib import Path

web = Path(__file__).resolve().parents[4]
out = Path(sys.argv[1]) if len(sys.argv) > 1 else web.parent / "TablePro" / ".github" / "assets" / "sponsors"
out.mkdir(parents=True, exist_ok=True)
for stale in out.glob("*.svg"):
stale.unlink()

data = json.loads((web / "resources/data/sponsors.json").read_text())
HEIGHT = 28 # the logo's height inside the tile, as on tablepro.app
PAD_X, PAD_Y, RADIUS = 8, 8, 6


def tile(inner_w, inner_h, body):
w, h = inner_w + 2 * PAD_X, inner_h + 2 * PAD_Y
return (
f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{w}" height="{h}" viewBox="0 0 {w} {h}">\n'
f'<rect width="{w}" height="{h}" rx="{RADIUS}" fill="#ffffff"/>\n{body}\n</svg>\n'
)


for sponsor in data["sponsors"]:
logo = sponsor["logo"]
if logo is None:
continue
width = round(HEIGHT * logo["width"] / logo["height"])
source = web / "public" / logo["light"].lstrip("/")
if source.suffix == ".svg":
text = source.read_text()
root = re.search(r"<svg\b[^>]*>", text).group(0)
view_box = re.search(r'viewBox="([^"]+)"', root)
view_box = view_box.group(1) if view_box else f"0 0 {logo['width']} {logo['height']}"
inner = text[text.index(root) + len(root): text.rindex("</svg>")]
assert not re.search(r"<script|<foreignObject|\bhref=\"(?!#)|xlink:href=\"(?!#)", inner), sponsor["id"]
# Keep the root's own paint attributes: a path that relies on the root's fill must still get it.
paint = " ".join(re.findall(r'\b(?:fill|stroke)="[^"]*"', root))
body = f'<svg x="{PAD_X}" y="{PAD_Y}" width="{width}" height="{HEIGHT}" viewBox="{view_box}" {paint}>{inner}</svg>'
else:
mime = {".png": "image/png", ".webp": "image/webp"}[source.suffix]
encoded = base64.b64encode(source.read_bytes()).decode()
body = f'<image x="{PAD_X}" y="{PAD_Y}" width="{width}" height="{HEIGHT}" href="data:{mime};base64,{encoded}"/>'
(out / f"{sponsor['id']}.svg").write_text(tile(width, HEIGHT, body))
print(sponsor["id"], width + 2 * PAD_X, HEIGHT + 2 * PAD_Y)
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ in the job with a live SSR service. It skips locally without one.
`facts.json` (URLs, limits), `sponsors.json`, `comparisons.json` (dated and
sourced). Copy refers to them through `{token}` slots; counts are derived,
never typed. After an app release, run `php artisan release:check`.
- `sponsors.json` holds each sponsor's placement and the tier prices, never
what a sponsor pays. The `sponsors` skill checks it against GitHub Sponsors
with the maintainer's `gh` login; the tier prices on GitHub and in the file
change together.
- Content images are slots in `resources/data/assets.json`, rendered by
`<AssetSlot id>` (or `<asset-slot id>` in markdown). Until the owner supplies
the file, a slot is a described placeholder outside production and renders
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/LlmsTxtController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class LlmsTxtController extends Controller
'landing.faq',
'landing.security',
'landing.about',
'landing.sponsors',
],
'Databases' => ['landing.databases.index', 'landing.databaseClient'],
'Comparisons' => ['landing.compare.index', 'landing.compare'],
Expand Down
21 changes: 21 additions & 0 deletions app/Http/Controllers/SponsorsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Http\Controllers;

use App\Services\Content\SiteFacts;
use App\Support\Content\ContentRepository;
use Illuminate\Support\Facades\App;
use Inertia\Inertia;
use Inertia\Response;

// The sponsors and tiers come from resources/data/sponsors.json, which the page imports itself.
class SponsorsController extends Controller
{
public function __invoke(ContentRepository $content, SiteFacts $site): Response
{
return Inertia::render('Sponsors', [
'content' => $content->page('sponsors', App::getLocale()),
'organizationProfiles' => $site->organizationProfiles(),
]);
}
}
3 changes: 2 additions & 1 deletion app/Support/Seo/StaticPages.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use App\Support\Localization\Locales;

/**
* The one-off pages: home, download, iOS, pricing, FAQ, about, security and the blog index.
* The one-off pages: home, download, iOS, pricing, FAQ, about, security, sponsors and the blog index.
*
* Each renders in a locale when `resources/data/content/{locale}/{page}.json`
* exists, and is indexable there unless that file sets `seo.indexable` to
Expand All @@ -29,6 +29,7 @@ final class StaticPages implements PageFamily
'landing.faq' => 'faq',
'landing.about' => 'about',
'landing.security' => 'security',
'landing.sponsors' => 'sponsors',
'landing.blog.index' => 'blog',
];

Expand Down
Loading