Skip to content

Commit a1d1c8c

Browse files
authored
Merge pull request #132 from TableProApp/chore/m18-sync-main
chore: merge main into m18/cloud
2 parents 9f697c4 + afdbcbe commit a1d1c8c

92 files changed

Lines changed: 1806 additions & 305 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/sponsors/SKILL.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
name: sponsors
3+
description: >
4+
Keeps resources/data/sponsors.json in step with GitHub Sponsors: who sponsors TablePro now, where
5+
each one is placed, the open homepage places and the tier prices. Use when the user says "check
6+
sponsors", "sponsor drift", "new sponsor", "add <company> as a sponsor", "a sponsor cancelled",
7+
"who sponsors us", or after changing the tiers on GitHub. Read-only against GitHub.
8+
---
9+
10+
# Sponsors
11+
12+
`resources/data/sponsors.json` is the only list the site reads: the homepage strip, `/sponsors`
13+
and the open-place count all come from it. Nothing in the app calls GitHub for sponsors, because
14+
GitHub's sponsorship API needs credentials and this repository holds none. This skill is the check.
15+
16+
Commands run from this repository's root.
17+
18+
## 1. Report
19+
20+
```bash
21+
python3 .claude/skills/sponsors/scripts/drift.py
22+
```
23+
24+
It runs `gh api graphql` with the maintainer's own `gh` login, for the user in
25+
`facts.json` → `links.sponsorsProgram` and the TableProApp organization. If `gh` reports a missing
26+
scope, the user runs `gh auth refresh -h github.com -s read:user` themselves.
27+
28+
| Code | Meaning | Fix |
29+
|---|---|---|
30+
| MISSING | A public sponsor whose amount earns a placement is not listed | Add it (step 2) |
31+
| GONE | A listed sponsor has no current public sponsorship | Remove the entry and its logo file |
32+
| PLACEMENT | Listed above or below what the amount earns | Change `placement` |
33+
| PRIVATE | A private sponsor is listed | Remove it: private sponsors are never named |
34+
| TIERS | Prices on GitHub and in `tiers` differ | Change whichever side is wrong, then rerun |
35+
| STALE | `verifiedAt` is over 30 days old | Rerun and set it once clean |
36+
| LEGACY | Info: kept from before the current tiers (`TIERS_SINCE` in the script) | Nothing |
37+
| EXPIRES | Info: a one-time placement ends within 30 days | Remove it after that date |
38+
39+
A one-time sponsorship counts for `oneTime.months` from its date, at `multiplier` times the
40+
monthly price. GitHub drops one-time sponsors from its own list after a month, so only the script
41+
tracks them.
42+
43+
## 2. Change the data
44+
45+
- One object per sponsor: `id` (kebab-case), `name` as the sponsor writes it, `githubLogin`,
46+
`placement` (`lead`, `homepage` or `name`), `url` with `?ref=tablepro` where the site
47+
takes one, and `logo` (`null` for `name`).
48+
- Display order is the array order; a `lead` is drawn first wherever it sits.
49+
- Logos: the file the sponsor sent, at `public/images/sponsors/{id}.svg|png|webp`, with its real
50+
`width` and `height` (an SVG needs both attributes on its root). A dark-mode file is
51+
`{id}-dark.*` with the same shape; without one the logo sits on a white tile. Never recolour or
52+
filter a logo. Check a third-party SVG holds no script, link or embedded image.
53+
- Set `verifiedAt` to the day of a clean report.
54+
55+
Never write an amount, a tier per sponsor, a private sponsor or the report itself into the
56+
repository: it is public. The report stays in the session.
57+
58+
## 3. The README
59+
60+
TablePro's README (TableProApp/TablePro) shows the same sponsors from `.github/assets/sponsors/`.
61+
Regenerate those files from `public/images/sponsors` and update the README's Sponsors section in a
62+
PR there, after this one deploys:
63+
64+
```bash
65+
python3 .claude/skills/sponsors/scripts/readme_tiles.py ../TablePro/.github/assets/sponsors
66+
```
67+
68+
## 4. Ship
69+
70+
```bash
71+
php artisan test --compact --filter='Sponsors|HomepageRender'
72+
npm run test:js
73+
```
74+
75+
Commit as `chore(sponsors): ...` on a branch and open a PR. `main` deploys itself.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env python3
2+
# Compares GitHub Sponsors with resources/data/sponsors.json and prints what differs.
3+
# Read-only: it runs queries through `gh`, which holds the credentials, and writes nothing.
4+
import argparse
5+
import json
6+
import subprocess
7+
import sys
8+
from datetime import date, datetime, timedelta
9+
from pathlib import Path
10+
11+
WEB_ROOT = Path(__file__).resolve().parents[4]
12+
DATA = WEB_ROOT / "resources" / "data" / "sponsors.json"
13+
FACTS = WEB_ROOT / "resources" / "data" / "facts.json"
14+
15+
# Sponsorships started or last changed before the current tiers went live keep the placement they had.
16+
TIERS_SINCE = date(2026, 10, 29)
17+
STALE_DAYS = 30
18+
EXPIRY_WARNING_DAYS = 30
19+
20+
SPONSORSHIPS = """
21+
query($login: String!, $endCursor: String) {
22+
%s(login: $login) {
23+
sponsorshipsAsMaintainer(first: 100, after: $endCursor, activeOnly: false, includePrivate: true) {
24+
pageInfo { hasNextPage endCursor }
25+
nodes {
26+
isActive isOneTimePayment privacyLevel createdAt tierSelectedAt
27+
tier { monthlyPriceInDollars isOneTime isCustomAmount }
28+
sponsorEntity { __typename ... on User { login name } ... on Organization { login name } }
29+
}
30+
}
31+
}
32+
}
33+
"""
34+
35+
TIERS = """
36+
query($login: String!) {
37+
user(login: $login) {
38+
sponsorsListing { tiers(first: 50) { nodes { monthlyPriceInDollars isOneTime isCustomAmount } } }
39+
}
40+
}
41+
"""
42+
43+
44+
def gh(query, **variables):
45+
args = ["gh", "api", "graphql", "-f", "query=" + query]
46+
for key, value in variables.items():
47+
if value is not None:
48+
args += ["-F", f"{key}={value}"]
49+
result = subprocess.run(args, capture_output=True, text=True)
50+
if result.returncode != 0:
51+
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())
52+
return json.loads(result.stdout)["data"]
53+
54+
55+
def sponsorships(kind, login):
56+
nodes, cursor = [], None
57+
while True:
58+
page = gh(SPONSORSHIPS % kind, login=login, endCursor=cursor)[kind]["sponsorshipsAsMaintainer"]
59+
nodes += page["nodes"]
60+
if not page["pageInfo"]["hasNextPage"]:
61+
return nodes
62+
cursor = page["pageInfo"]["endCursor"]
63+
64+
65+
def day(stamp):
66+
return datetime.fromisoformat(stamp.replace("Z", "+00:00")).date()
67+
68+
69+
def rank(placement, data):
70+
order = [tier["placement"] for tier in data["tiers"]]
71+
return -1 if placement is None else order.index(placement)
72+
73+
74+
def earned(amount, one_time, data):
75+
multiplier = data["oneTime"]["multiplier"] if one_time else 1
76+
placement = None
77+
for tier in data["tiers"]:
78+
if amount >= tier["monthly"] * multiplier:
79+
placement = tier["placement"]
80+
return placement
81+
82+
83+
def price_label(amount, one_time):
84+
return f"${amount}" + (" one time" if one_time else " a month")
85+
86+
87+
def main():
88+
parser = argparse.ArgumentParser(description=__doc__)
89+
parser.add_argument("--today", type=date.fromisoformat, default=date.today())
90+
args = parser.parse_args()
91+
today = args.today
92+
93+
data = json.loads(DATA.read_text())
94+
facts = json.loads(FACTS.read_text())
95+
program = facts["links"]["sponsorsProgram"]
96+
maintainer = program.rstrip("/").rsplit("/", 1)[-1]
97+
months = data["oneTime"]["months"]
98+
99+
nodes = sponsorships("user", maintainer) + sponsorships("organization", "TableProApp")
100+
listed = {sponsor["githubLogin"].lower(): sponsor for sponsor in data["sponsors"]}
101+
102+
drift = 0
103+
104+
def report(code, text, counts=True):
105+
nonlocal drift
106+
drift += counts
107+
print(f"{code:<10} {text}")
108+
109+
current, private = {}, 0
110+
for node in nodes:
111+
entity = node["sponsorEntity"]
112+
if entity is None or node["tier"] is None:
113+
continue
114+
started = day(node["createdAt"])
115+
changed = day(node["tierSelectedAt"] or node["createdAt"])
116+
one_time = node["isOneTimePayment"]
117+
ends = started + timedelta(days=round(months * 365 / 12)) if one_time else None
118+
live = (ends is not None and ends > today) if one_time else node["isActive"]
119+
if not live:
120+
continue
121+
if node["privacyLevel"] != "PUBLIC":
122+
private += 1
123+
if entity["login"].lower() in listed:
124+
report("PRIVATE", f"{entity['login']} sponsors privately but is listed: remove it")
125+
continue
126+
sponsor = {
127+
"login": entity["login"],
128+
"name": entity.get("name") or entity["login"],
129+
"kind": entity["__typename"],
130+
"amount": node["tier"]["monthlyPriceInDollars"],
131+
"oneTime": one_time,
132+
"ends": ends,
133+
"legacy": changed < TIERS_SINCE,
134+
"placement": earned(node["tier"]["monthlyPriceInDollars"], one_time, data),
135+
}
136+
# One login can hold a monthly and a one-time sponsorship at once: the better placement counts.
137+
known = current.get(entity["login"].lower())
138+
if known is None or rank(sponsor["placement"], data) > rank(known["placement"], data):
139+
current[entity["login"].lower()] = sponsor
140+
141+
for login, sponsor in sorted(current.items()):
142+
price = price_label(sponsor["amount"], sponsor["oneTime"])
143+
entry = listed.get(login)
144+
if entry is None:
145+
if sponsor["placement"] is not None:
146+
report("MISSING", f"{sponsor['login']} ({sponsor['name']}, {sponsor['kind']}, {price}) earns placement '{sponsor['placement']}'")
147+
continue
148+
if sponsor["placement"] != entry["placement"]:
149+
earns = sponsor["placement"] or "no placement"
150+
if sponsor["legacy"]:
151+
report("LEGACY", f"{sponsor['login']} ({price}) keeps '{entry['placement']}' from before {TIERS_SINCE}", counts=False)
152+
else:
153+
report("PLACEMENT", f"{sponsor['login']} ({price}) is listed as '{entry['placement']}' but earns {earns}")
154+
if sponsor["ends"] is not None and (sponsor["ends"] - today).days <= EXPIRY_WARNING_DAYS:
155+
report("EXPIRES", f"{sponsor['login']}'s one-time placement ends on {sponsor['ends']}", counts=False)
156+
157+
for login, entry in sorted(listed.items()):
158+
if login not in current:
159+
report("GONE", f"{entry['githubLogin']} ({entry['name']}) has no current public sponsorship: remove it and its logo")
160+
161+
tiers = gh(TIERS, login=maintainer)["user"]["sponsorsListing"]["tiers"]["nodes"]
162+
published = {(tier["monthlyPriceInDollars"], tier["isOneTime"]) for tier in tiers if not tier["isCustomAmount"]}
163+
wanted = {(tier["monthly"], False) for tier in data["tiers"]}
164+
wanted |= {(tier["monthly"] * data["oneTime"]["multiplier"], True) for tier in data["tiers"]}
165+
for amount, one_time in sorted(wanted - published):
166+
report("TIERS", f"{price_label(amount, one_time)} is in sponsors.json but not published on GitHub")
167+
for amount, one_time in sorted(published - wanted):
168+
report("TIERS", f"{price_label(amount, one_time)} is published on GitHub but not in sponsors.json")
169+
170+
on_homepage = sum(entry["placement"] in ("lead", "homepage") for entry in data["sponsors"])
171+
age = (today - date.fromisoformat(data["verifiedAt"])).days
172+
if age > STALE_DAYS:
173+
report("STALE", f"verifiedAt is {age} days old")
174+
175+
print(f"\n{len(current)} current public sponsors, {private} private (never listed), "
176+
f"{max(0, data['homepageSlots'] - on_homepage)} of {data['homepageSlots']} homepage places open.")
177+
print("No drift." if drift == 0 else f"{drift} to fix.")
178+
return 1 if drift else 0
179+
180+
181+
if __name__ == "__main__":
182+
sys.exit(main())
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env python3
2+
# Writes the README's sponsor logos, each on a white tile so a dark logo stays visible in GitHub's dark theme.
3+
# Usage: readme_tiles.py [path to TablePro's .github/assets/sponsors]
4+
import base64
5+
import json
6+
import re
7+
import sys
8+
from pathlib import Path
9+
10+
web = Path(__file__).resolve().parents[4]
11+
out = Path(sys.argv[1]) if len(sys.argv) > 1 else web.parent / "TablePro" / ".github" / "assets" / "sponsors"
12+
out.mkdir(parents=True, exist_ok=True)
13+
for stale in out.glob("*.svg"):
14+
stale.unlink()
15+
16+
data = json.loads((web / "resources/data/sponsors.json").read_text())
17+
HEIGHT = 28 # the logo's height inside the tile, as on tablepro.app
18+
PAD_X, PAD_Y, RADIUS = 8, 8, 6
19+
20+
21+
def tile(inner_w, inner_h, body):
22+
w, h = inner_w + 2 * PAD_X, inner_h + 2 * PAD_Y
23+
return (
24+
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'
25+
f'<rect width="{w}" height="{h}" rx="{RADIUS}" fill="#ffffff"/>\n{body}\n</svg>\n'
26+
)
27+
28+
29+
for sponsor in data["sponsors"]:
30+
logo = sponsor["logo"]
31+
if logo is None:
32+
continue
33+
width = round(HEIGHT * logo["width"] / logo["height"])
34+
source = web / "public" / logo["light"].lstrip("/")
35+
if source.suffix == ".svg":
36+
text = source.read_text()
37+
root = re.search(r"<svg\b[^>]*>", text).group(0)
38+
view_box = re.search(r'viewBox="([^"]+)"', root)
39+
view_box = view_box.group(1) if view_box else f"0 0 {logo['width']} {logo['height']}"
40+
inner = text[text.index(root) + len(root): text.rindex("</svg>")]
41+
assert not re.search(r"<script|<foreignObject|\bhref=\"(?!#)|xlink:href=\"(?!#)", inner), sponsor["id"]
42+
# Keep the root's own paint attributes: a path that relies on the root's fill must still get it.
43+
paint = " ".join(re.findall(r'\b(?:fill|stroke)="[^"]*"', root))
44+
body = f'<svg x="{PAD_X}" y="{PAD_Y}" width="{width}" height="{HEIGHT}" viewBox="{view_box}" {paint}>{inner}</svg>'
45+
else:
46+
mime = {".png": "image/png", ".webp": "image/webp"}[source.suffix]
47+
encoded = base64.b64encode(source.read_bytes()).decode()
48+
body = f'<image x="{PAD_X}" y="{PAD_Y}" width="{width}" height="{HEIGHT}" href="data:{mime};base64,{encoded}"/>'
49+
(out / f"{sponsor['id']}.svg").write_text(tile(width, HEIGHT, body))
50+
print(sponsor["id"], width + 2 * PAD_X, HEIGHT + 2 * PAD_Y)

‎CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ in the job with a live SSR service. It skips locally without one.
168168
`facts.json` (URLs, limits), `sponsors.json`, `comparisons.json` (dated and
169169
sourced). Copy refers to them through `{token}` slots; counts are derived,
170170
never typed. After an app release, run `php artisan release:check`.
171+
- `sponsors.json` holds each sponsor's placement and the tier prices, never
172+
what a sponsor pays. The `sponsors` skill checks it against GitHub Sponsors
173+
with the maintainer's `gh` login; the tier prices on GitHub and in the file
174+
change together.
171175
- Content images are slots in `resources/data/assets.json`, rendered by
172176
`<AssetSlot id>` (or `<asset-slot id>` in markdown). Until the owner supplies
173177
the file, a slot is a described placeholder outside production and renders

‎app/Http/Controllers/LlmsTxtController.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class LlmsTxtController extends Controller
3535
'landing.faq',
3636
'landing.security',
3737
'landing.about',
38+
'landing.sponsors',
3839
],
3940
'Databases' => ['landing.databases.index', 'landing.databaseClient'],
4041
'Comparisons' => ['landing.compare.index', 'landing.compare'],
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Services\Content\SiteFacts;
6+
use App\Support\Content\ContentRepository;
7+
use Illuminate\Support\Facades\App;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
// The sponsors and tiers come from resources/data/sponsors.json, which the page imports itself.
12+
class SponsorsController extends Controller
13+
{
14+
public function __invoke(ContentRepository $content, SiteFacts $site): Response
15+
{
16+
return Inertia::render('Sponsors', [
17+
'content' => $content->page('sponsors', App::getLocale()),
18+
'organizationProfiles' => $site->organizationProfiles(),
19+
]);
20+
}
21+
}

‎app/Support/Seo/StaticPages.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use App\Support\Localization\Locales;
77

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

0 commit comments

Comments
 (0)