From a043fe9e7cfcf9a78491f146c9b629194d7d0ecb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 15:59:45 +0700 Subject: [PATCH 1/3] feat(sponsors): add a sponsors page with tiers and the open homepage places --- .claude/skills/sponsors/SKILL.md | 65 +++++++ .claude/skills/sponsors/scripts/drift.py | 182 ++++++++++++++++++ CLAUDE.md | 4 + app/Http/Controllers/LlmsTxtController.php | 1 + app/Http/Controllers/SponsorsController.php | 21 ++ app/Support/Seo/StaticPages.php | 3 +- docs/rebuild/design/design-system.md | 10 +- docs/rebuild/design/sitemap.md | 4 +- public/images/sponsors/getapps-cafe.png | Bin 35798 -> 0 bytes public/images/sponsors/pyto.svg | 12 ++ public/images/sponsors/unikorn.svg | 108 ----------- public/images/sponsors/visnalize.svg | 1 - public/images/sponsors/xermius.webp | Bin 6734 -> 0 bytes resources/data/content/de/about.json | 2 +- resources/data/content/de/home.json | 9 +- resources/data/content/de/sponsors.json | 47 +++++ resources/data/content/en/about.json | 2 +- resources/data/content/en/home.json | 9 +- resources/data/content/en/sponsors.json | 47 +++++ resources/data/content/es/about.json | 2 +- resources/data/content/es/home.json | 9 +- resources/data/content/es/sponsors.json | 47 +++++ resources/data/content/fr/about.json | 2 +- resources/data/content/fr/home.json | 9 +- resources/data/content/fr/sponsors.json | 47 +++++ resources/data/content/id/about.json | 2 +- resources/data/content/id/home.json | 9 +- resources/data/content/id/sponsors.json | 47 +++++ resources/data/content/it/about.json | 2 +- resources/data/content/it/home.json | 9 +- resources/data/content/it/sponsors.json | 47 +++++ resources/data/content/ja/about.json | 2 +- resources/data/content/ja/home.json | 9 +- resources/data/content/ja/sponsors.json | 47 +++++ resources/data/content/ko/about.json | 2 +- resources/data/content/ko/home.json | 9 +- resources/data/content/ko/sponsors.json | 47 +++++ resources/data/content/pt-BR/about.json | 2 +- resources/data/content/pt-BR/home.json | 9 +- resources/data/content/pt-BR/sponsors.json | 47 +++++ resources/data/content/vi/about.json | 2 +- resources/data/content/vi/home.json | 9 +- resources/data/content/vi/sponsors.json | 47 +++++ resources/data/content/zh-Hans/about.json | 2 +- resources/data/content/zh-Hans/home.json | 9 +- resources/data/content/zh-Hans/sponsors.json | 47 +++++ resources/data/content/zh-Hant/about.json | 2 +- resources/data/content/zh-Hant/home.json | 9 +- resources/data/content/zh-Hant/sponsors.json | 47 +++++ resources/data/sponsors.json | 32 ++- resources/js/components/faq/content-links.tsx | 2 +- .../js/components/home/sponsors-section.tsx | 96 +++------ resources/js/components/site/site-footer.tsx | 4 +- resources/js/components/site/site-links.ts | 1 - .../js/components/sponsors/sponsor-grid.tsx | 69 +++++++ resources/js/lib/attribution.ts | 2 +- resources/js/lib/data/sponsors.ts | 34 ++-- resources/js/lib/sponsors.ts | 20 ++ resources/js/pages/Sponsors.tsx | 137 +++++++++++++ routes/localized.php | 2 + tests/Feature/Chrome/PageFrameTest.php | 2 +- tests/Feature/Chrome/SiteChromeTest.php | 7 +- tests/Feature/Data/LocalesDataTest.php | 2 +- tests/Feature/Data/SponsorsDataTest.php | 116 +++++++++-- tests/Feature/Landing/AboutPageTest.php | 3 + tests/Feature/Landing/HomepageRenderTest.php | 30 ++- tests/Feature/Landing/LandingTest.php | 1 + tests/Feature/Landing/SponsorsPageTest.php | 132 +++++++++++++ .../Localization/LocaleRoutingTest.php | 2 +- tests/js/sponsors.test.ts | 45 +++++ 70 files changed, 1589 insertions(+), 257 deletions(-) create mode 100644 .claude/skills/sponsors/SKILL.md create mode 100644 .claude/skills/sponsors/scripts/drift.py create mode 100644 app/Http/Controllers/SponsorsController.php delete mode 100644 public/images/sponsors/getapps-cafe.png create mode 100644 public/images/sponsors/pyto.svg delete mode 100644 public/images/sponsors/unikorn.svg delete mode 100644 public/images/sponsors/visnalize.svg delete mode 100644 public/images/sponsors/xermius.webp create mode 100644 resources/data/content/de/sponsors.json create mode 100644 resources/data/content/en/sponsors.json create mode 100644 resources/data/content/es/sponsors.json create mode 100644 resources/data/content/fr/sponsors.json create mode 100644 resources/data/content/id/sponsors.json create mode 100644 resources/data/content/it/sponsors.json create mode 100644 resources/data/content/ja/sponsors.json create mode 100644 resources/data/content/ko/sponsors.json create mode 100644 resources/data/content/pt-BR/sponsors.json create mode 100644 resources/data/content/vi/sponsors.json create mode 100644 resources/data/content/zh-Hans/sponsors.json create mode 100644 resources/data/content/zh-Hant/sponsors.json create mode 100644 resources/js/components/sponsors/sponsor-grid.tsx create mode 100644 resources/js/lib/sponsors.ts create mode 100644 resources/js/pages/Sponsors.tsx create mode 100644 tests/Feature/Landing/SponsorsPageTest.php create mode 100644 tests/js/sponsors.test.ts diff --git a/.claude/skills/sponsors/SKILL.md b/.claude/skills/sponsors/SKILL.md new file mode 100644 index 00000000..57c20f9c --- /dev/null +++ b/.claude/skills/sponsors/SKILL.md @@ -0,0 +1,65 @@ +--- +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 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. 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. diff --git a/.claude/skills/sponsors/scripts/drift.py b/.claude/skills/sponsors/scripts/drift.py new file mode 100644 index 00000000..d2e341c1 --- /dev/null +++ b/.claude/skills/sponsors/scripts/drift.py @@ -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()) diff --git a/CLAUDE.md b/CLAUDE.md index 8afc3fa4..4a77ea43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` (or `` in markdown). Until the owner supplies the file, a slot is a described placeholder outside production and renders diff --git a/app/Http/Controllers/LlmsTxtController.php b/app/Http/Controllers/LlmsTxtController.php index 260e50a5..aa4a6fe0 100644 --- a/app/Http/Controllers/LlmsTxtController.php +++ b/app/Http/Controllers/LlmsTxtController.php @@ -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'], diff --git a/app/Http/Controllers/SponsorsController.php b/app/Http/Controllers/SponsorsController.php new file mode 100644 index 00000000..d5c809ae --- /dev/null +++ b/app/Http/Controllers/SponsorsController.php @@ -0,0 +1,21 @@ + $content->page('sponsors', App::getLocale()), + 'organizationProfiles' => $site->organizationProfiles(), + ]); + } +} diff --git a/app/Support/Seo/StaticPages.php b/app/Support/Seo/StaticPages.php index 79d158fb..53ad63ae 100644 --- a/app/Support/Seo/StaticPages.php +++ b/app/Support/Seo/StaticPages.php @@ -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 @@ -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', ]; diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index bb37a09e..5acdc1de 100644 --- a/docs/rebuild/design/design-system.md +++ b/docs/rebuild/design/design-system.md @@ -597,7 +597,7 @@ Repo key: | `IntegrationFilters` | P | **New** | — | `/integrations` only: a `role="search"` GET form of native controls (search, category, TablePro app, type). Results update as you type once the page has hydrated | | `IntegrationList` | P | **New** | — | Integrations as ruled rows, rail to rail: mark, name, publisher, type, tagline. Not a card grid | | `DatabaseMark` | P | **Refactor** | `ui/database-mark.tsx` | Original colours, no `grayscale` / `opacity-40` / `invert`. Uses `iconDark` from data or a light tile | -| `SponsorList` | P | **Refactor** | `landing/sponsor-row.tsx` | The 4 verified sponsors only. Each link named | +| `SponsorList` | P | **Refactor** | `sponsors/sponsor-grid.tsx` | The homepage-placed sponsors from `sponsors.json`, then one invite cell. Each link named | | `ProseArticle` | P | **Refactor** | `.blog-article` CSS, `ui/prose-block.tsx` | Blog and legal. Body in `--foreground`. Permalink glyph kept out of the heading's accessible name | | `TableOfContents` | P | **New** (optional) | — | Legal and long guides: sticky at ≥ 1280, a `Disclosure` below that | | `AccountBar`, `AccountNav` | A | **Refactor** | A `dashboard/account-bar.tsx`, `nav-rail.tsx` | Adds language and theme. Rail bar uses `--accent-indicator` | @@ -991,10 +991,10 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and **SponsorList:** -- Section 3 on the homepage. -- An H2 in the `h3` style, then four logo links at 28px tall with gaps of 32/48px. +- Section 3 on the homepage, and the `#companies` section of `/sponsors`. +- An H2 in the `h3` style, then one cell per sponsor with the logo 28px tall. A `lead` sponsor spans two cells with a 44px logo. - Each link is named (visible name in `caption` under the logo, or `aria-label`) with `rel="sponsored noopener"`. -- Then "Sponsor TablePro ↗" (GitHub Sponsors; sitemap §D, positioning §10.2). +- On the homepage, an invite cell after the last logo ("Your logo here", "7 of 12 open") and the heading link both go to `/sponsors`; the cell is hidden when no place is open. Only `/sponsors` links to GitHub Sponsors. - Logos follow the DatabaseMark rules in dark mode. #### 5.3.19 ProseArticle and TableOfContents @@ -1382,7 +1382,7 @@ SiteHeader one small line: built-in drivers vs drivers downloaded on first pick · the engines that open on iPhone (names) Supported databases → (/databases) §3 SPONSORS #sponsors compact · flush: the logo cells close on the next join - H2 (h3 style) · SponsorList (the 4 verified sponsors, cells 2 × 2, four across from 768) · Sponsor TablePro ↗ + H2 (h3 style) · SponsorList (homepage sponsors and an invite cell, two across, four from 768) · Sponsor TablePro → §4 WORKFLOWS #features H2, then five FeatureRows (H3 each) as cells, flush, in this order Query ▒ mac-query-autocomplete window row Querying → Edit data ▒ mac-edit-preview-sql window row Data editing → · Schema → diff --git a/docs/rebuild/design/sitemap.md b/docs/rebuild/design/sitemap.md index 29cabfa5..aba3f071 100644 --- a/docs/rebuild/design/sitemap.md +++ b/docs/rebuild/design/sitemap.md @@ -67,7 +67,7 @@ Column notes: - `pricing.json`: tiers, cycles, prices, seats, activations, refund window, license timings, the 10 paid features with tier, page anchor and lapse behaviour, merchant, currency, and the definition of Priority support. - `facts.json`: AI provider list, MCP clients, Safe Mode levels per platform, import sources, export formats, backup tools, sync categories, grid and editor limits. - `comparisons.json`: facts plus `checkedAt`, `sources[]` and three-state cells. - - `sponsors.json`: the 4 verified sponsors (CodeRabbit, SimpleLocalize, Nimbus, Dwarves Foundation). + - `sponsors.json`: the current sponsors with their placement, the homepage places and the tier prices. - `assets.json`: the placeholder manifest. - **Slots** are asset IDs from the slot catalogue in §A.8. One ID means one scene. A scene is reused only where it shows the same workflow on the same engine. - **Indexable "yes (pair)"** means `index,follow`, in the sitemap, self-canonical, with reciprocal `hreflang` between EN and VI and `x-default` set to EN. **"yes (EN only)"** means indexable, with no alternates. @@ -499,7 +499,7 @@ The same structure serves `/` and `/vi`. The ids are locale-neutral. |---|---|---|---|---|---|---| | 1 | `top` | **Hero** | What is TablePro, and can I use it on my devices? | H1 and subtitle (positioning decision). "Download for Mac" → /download, with the availability line below it ("macOS 13 Ventura or later · Apple Silicon and Intel"). The App Store badge, with its own line ("iPhone and iPad · iOS/iPadOS 18 or later · free"). A small link to Pricing | mac-hero-window (≥768px), mac-hero-window-mobile (<768px); the only `fetchpriority="high"` image on the site | platforms; `download_click{location:'hero', platform}` | | 2 | `databases` | **Databases** | Does it work with the database I use? | The featured engines with vendor marks; the number of engines, derived, with the hub's categories as links into /databases; one sentence on bundled drivers and drivers downloaded on first pick; the engines that open on iPhone; "Supported databases →" /databases | none (vendor marks are identity assets) | engines (names and categories; any count is derived) | -| 3 | `sponsors` | **Sponsors** (fixed third) | Who supports the project? | The 4 verified sponsors' logos with `rel="sponsored noopener"` and accessible names; "Sponsor TablePro" (GitHub Sponsors) | none (sponsor logos are identity) | sponsors.json | +| 3 | `sponsors` | **Sponsors** (fixed third) | Who supports the project? | The homepage-placed sponsors' logos with `rel="sponsored noopener"` and accessible names, an invite cell with the open places, and "Sponsor TablePro" (both to `/sponsors`) | none (sponsor logos are identity) | sponsors.json | | 4 | `features` | **Workflows** | What do I do with it day to day? | Five rows, each a heading, 2-3 sentences, one slot and a link: **Query** → /features/querying · **Edit data** → /features/data-editing · **Schemas and sync** (Compare & Sync marked Starter) → /features/schema · **Files** → /features/import-export · **Connect** → /features/connections | mac-query-autocomplete, mac-edit-preview-sql, mac-compare-sync-structure, mac-data-files-window, mac-connection-ssh-form | pricing (tier markers) | | 5 | `safety` | **Production safety** | Can I point it at production without fear? | Safe Mode per connection (levels named); DROP, TRUNCATE and WHERE-less DELETE always ask; Read-Only; Touch ID; Agent mode raises the floor → /features/data-editing#safe-mode | mac-safe-mode-touchid | facts (Safe Mode levels) | | 6 | `ai` (`mcp`) | **AI and MCP** | Does it work with AI tools, and what can the AI touch? | Bring your own provider (named); Agent mode; a local MCP server that is off by default; the permission layers in one line; free → /features/ai-mcp | mac-ai-chat | facts (providers) | diff --git a/public/images/sponsors/getapps-cafe.png b/public/images/sponsors/getapps-cafe.png deleted file mode 100644 index 8ed0b29a0ad2eaef400d5282e09874dda980f3a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35798 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 KpUXO@geCx5_Fi@X diff --git a/public/images/sponsors/pyto.svg b/public/images/sponsors/pyto.svg new file mode 100644 index 00000000..3653e9c1 --- /dev/null +++ b/public/images/sponsors/pyto.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/sponsors/unikorn.svg b/public/images/sponsors/unikorn.svg deleted file mode 100644 index 2ab18197..00000000 --- a/public/images/sponsors/unikorn.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/sponsors/visnalize.svg b/public/images/sponsors/visnalize.svg deleted file mode 100644 index 21bb493f..00000000 --- a/public/images/sponsors/visnalize.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/images/sponsors/xermius.webp b/public/images/sponsors/xermius.webp deleted file mode 100644 index 7a91594d54d6a598ab12b0008fc71fd0d53d2ad9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6734 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 ZO3OsbKv&De)I`d{O5X^fFRdr7@gG1QxSaq1 diff --git a/resources/data/content/de/about.json b/resources/data/content/de/about.json index 2dbd76ae..855da135 100644 --- a/resources/data/content/de/about.json +++ b/resources/data/content/de/about.json @@ -28,7 +28,7 @@ "title": "Finanzierung", "body": "Bezahlte Lizenzen und Sponsoren finanzieren TablePro. Es hat keine Investoren und verkauft keine Daten.", "plans": "Eine Starter- oder Team-Lizenz ergänzt die Mac-App um optionale Funktionen; die Preisseite listet sie auf.", - "sponsors": "Die aktuellen Sponsoren stehen auf der Startseite. Sponsoring läuft über GitHub Sponsors." + "sponsors": "Die aktuellen Sponsoren und was jede Stufe umfasst, stehen auf der Sponsorenseite." }, "source": { "title": "Quellcode", diff --git a/resources/data/content/de/home.json b/resources/data/content/de/home.json index 50171a2f..5da7a4b1 100644 --- a/resources/data/content/de/home.json +++ b/resources/data/content/de/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsoren", - "link": "TablePro unterstützen" + "link": "TablePro unterstützen", + "invite": { + "title": "Dein Logo hier", + "open": { + "one": "{count} von {total} frei", + "other": "{count} von {total} frei" + } + } }, "workflows": { "title": "Mit deinen Datenbanken arbeiten", diff --git a/resources/data/content/de/sponsors.json b/resources/data/content/de/sponsors.json new file mode 100644 index 00000000..3f33c3cd --- /dev/null +++ b/resources/data/content/de/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsoren", + "description": "Die Unternehmen und Menschen, die TablePro sponsern, was jede Sponsoring-Stufe umfasst und wie du über GitHub Sponsors Sponsor wirst." + }, + "og": { + "kicker": "Sponsoren", + "title": "TablePro unterstützen" + }, + "breadcrumb": "Sponsoren", + "header": { + "title": "Sponsoren", + "lead": "TablePro ist Open Source. Sponsoren bezahlen die Zeit, die in Entwicklung und Pflege fließt.", + "action": "Auf GitHub sponsern" + }, + "companies": { + "title": "Unternehmen", + "open": { + "one": "{count} von {total} Logoplätzen auf der Startseite ist frei.", + "other": "{count} von {total} Logoplätzen auf der Startseite sind frei." + }, + "full": "Auf der Startseite ist keiner der {total} Logoplätze mehr frei." + }, + "people": { + "title": "Einzelpersonen" + }, + "tiers": { + "title": "Stufen", + "lead": { + "one": "Sponsere monatlich oder einmalig für {count} Monat mit derselben Platzierung.", + "other": "Sponsere monatlich oder einmalig für {count} Monate mit derselben Platzierung." + }, + "caption": "Sponsoring-Stufen mit monatlichem Preis, einmaligem Preis und dem, was jede Stufe umfasst", + "columns": { + "monthly": "Monatlich", + "oneTime": "Einmalig", + "benefit": "Das bekommst du" + }, + "oneTimePrice": "{price} einmalig", + "benefit": { + "name": "Dein Name mit Link auf dieser Seite und in der README von TablePro.", + "homepage": "Dein Logo mit Link auf der Startseite, auf dieser Seite und in der README von TablePro. Die Startseite bietet Platz für {total}.", + "lead": "Dein Logo zuerst und größer auf der Startseite, auf dieser Seite und in der README." + }, + "link": "TablePro auf GitHub sponsern" + } +} diff --git a/resources/data/content/en/about.json b/resources/data/content/en/about.json index 4005520a..de26072d 100644 --- a/resources/data/content/en/about.json +++ b/resources/data/content/en/about.json @@ -28,7 +28,7 @@ "title": "How it is funded", "body": "Paid licenses and sponsors fund TablePro. It has no investors and sells no data.", "plans": "A Starter or Team license adds optional features to the Mac app; the pricing page lists them.", - "sponsors": "The current sponsors are listed on the homepage. Sponsorship goes through GitHub Sponsors." + "sponsors": "The current sponsors, and what each sponsorship includes, are on the sponsors page." }, "source": { "title": "Source code", diff --git a/resources/data/content/en/home.json b/resources/data/content/en/home.json index 895fdc8e..e4a87396 100644 --- a/resources/data/content/en/home.json +++ b/resources/data/content/en/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsors", - "link": "Sponsor TablePro" + "link": "Sponsor TablePro", + "invite": { + "title": "Your logo here", + "open": { + "one": "{count} of {total} open", + "other": "{count} of {total} open" + } + } }, "workflows": { "title": "Working with your databases", diff --git a/resources/data/content/en/sponsors.json b/resources/data/content/en/sponsors.json new file mode 100644 index 00000000..0cf28a77 --- /dev/null +++ b/resources/data/content/en/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsors", + "description": "The companies and people who sponsor TablePro, what each sponsorship tier includes, and how to become a sponsor through GitHub Sponsors." + }, + "og": { + "kicker": "Sponsors", + "title": "Sponsor TablePro" + }, + "breadcrumb": "Sponsors", + "header": { + "title": "Sponsors", + "lead": "TablePro is open source. Sponsors pay for the time that goes into building and maintaining it.", + "action": "Sponsor on GitHub" + }, + "companies": { + "title": "Companies", + "open": { + "one": "{count} of the {total} places on the homepage is open.", + "other": "{count} of the {total} places on the homepage are open." + }, + "full": "All {total} places on the homepage are taken." + }, + "people": { + "title": "Individuals" + }, + "tiers": { + "title": "Tiers", + "lead": { + "one": "Sponsor monthly, or once for {count} month of the same placement.", + "other": "Sponsor monthly, or once for {count} months of the same placement." + }, + "caption": "Sponsorship tiers with the monthly price, the one-time price and what each includes", + "columns": { + "monthly": "Monthly", + "oneTime": "One time", + "benefit": "What you get" + }, + "oneTimePrice": "{price} one time", + "benefit": { + "name": "Your name and link on this page and in the TablePro README.", + "homepage": "Your logo and link on the homepage, this page and the TablePro README. The homepage has room for {total}.", + "lead": "Your logo first and larger on the homepage, this page and the README." + }, + "link": "Sponsor TablePro on GitHub" + } +} diff --git a/resources/data/content/es/about.json b/resources/data/content/es/about.json index 77f5dcb5..1f891f44 100644 --- a/resources/data/content/es/about.json +++ b/resources/data/content/es/about.json @@ -28,7 +28,7 @@ "title": "Financiación", "body": "Las licencias de pago y los patrocinadores financian TablePro. No tiene inversores ni vende datos.", "plans": "Una licencia Starter o Team añade funciones opcionales a la app para Mac; la página de precios las enumera.", - "sponsors": "Los patrocinadores actuales aparecen en la página de inicio. El patrocinio se gestiona a través de GitHub Sponsors." + "sponsors": "Los patrocinadores actuales y lo que incluye cada nivel aparecen en la página de patrocinadores." }, "source": { "title": "Código fuente", diff --git a/resources/data/content/es/home.json b/resources/data/content/es/home.json index cbcb6eeb..12f61166 100644 --- a/resources/data/content/es/home.json +++ b/resources/data/content/es/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Patrocinadores", - "link": "Patrocinar TablePro" + "link": "Patrocinar TablePro", + "invite": { + "title": "Tu logo aquí", + "open": { + "one": "{count} de {total} libre", + "other": "{count} de {total} libres" + } + } }, "workflows": { "title": "Trabaja con tus bases de datos", diff --git a/resources/data/content/es/sponsors.json b/resources/data/content/es/sponsors.json new file mode 100644 index 00000000..cf24d47d --- /dev/null +++ b/resources/data/content/es/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Patrocinadores", + "description": "Las empresas y personas que patrocinan TablePro, qué incluye cada nivel de patrocinio y cómo patrocinarlo a través de GitHub Sponsors." + }, + "og": { + "kicker": "Patrocinadores", + "title": "Patrocinar TablePro" + }, + "breadcrumb": "Patrocinadores", + "header": { + "title": "Patrocinadores", + "lead": "TablePro es de código abierto. Los patrocinadores pagan el tiempo que se dedica a desarrollarlo y mantenerlo.", + "action": "Patrocinar en GitHub" + }, + "companies": { + "title": "Empresas", + "open": { + "one": "Queda {count} de {total} espacios en la página de inicio.", + "other": "Quedan {count} de {total} espacios en la página de inicio." + }, + "full": "Los {total} espacios de la página de inicio están ocupados." + }, + "people": { + "title": "Personas" + }, + "tiers": { + "title": "Niveles", + "lead": { + "one": "Patrocina cada mes, o una sola vez por {count} mes con la misma ubicación.", + "other": "Patrocina cada mes, o una sola vez por {count} meses con la misma ubicación." + }, + "caption": "Niveles de patrocinio con el precio mensual, el precio único y lo que incluye cada uno", + "columns": { + "monthly": "Mensual", + "oneTime": "Una vez", + "benefit": "Qué incluye" + }, + "oneTimePrice": "{price} una vez", + "benefit": { + "name": "Tu nombre y un enlace en esta página y en el README de TablePro.", + "homepage": "Tu logo y un enlace en la página de inicio, en esta página y en el README de TablePro. La página de inicio tiene {total} espacios.", + "lead": "Tu logo primero y más grande en la página de inicio, en esta página y en el README." + }, + "link": "Patrocinar TablePro en GitHub" + } +} diff --git a/resources/data/content/fr/about.json b/resources/data/content/fr/about.json index c14bd2a4..d655b329 100644 --- a/resources/data/content/fr/about.json +++ b/resources/data/content/fr/about.json @@ -28,7 +28,7 @@ "title": "Financement", "body": "Les licences payantes et les sponsors financent TablePro. Le projet n’a aucun investisseur et ne vend aucune donnée.", "plans": "Une licence Starter ou Team ajoute des fonctionnalités facultatives à l’application Mac, listées sur la page des tarifs.", - "sponsors": "Les sponsors actuels figurent sur la page d’accueil. Le parrainage passe par GitHub Sponsors." + "sponsors": "Les sponsors actuels et ce que comprend chaque niveau figurent sur la page des sponsors." }, "source": { "title": "Code source", diff --git a/resources/data/content/fr/home.json b/resources/data/content/fr/home.json index 6aed62d0..9bb6ca35 100644 --- a/resources/data/content/fr/home.json +++ b/resources/data/content/fr/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsors", - "link": "Soutenir TablePro" + "link": "Soutenir TablePro", + "invite": { + "title": "Votre logo ici", + "open": { + "one": "{count} place libre sur {total}", + "other": "{count} places libres sur {total}" + } + } }, "workflows": { "title": "Travailler avec vos bases de données", diff --git a/resources/data/content/fr/sponsors.json b/resources/data/content/fr/sponsors.json new file mode 100644 index 00000000..c6145adb --- /dev/null +++ b/resources/data/content/fr/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsors", + "description": "Les entreprises et les personnes qui sponsorisent TablePro, ce que comprend chaque niveau de parrainage et comment devenir sponsor via GitHub Sponsors." + }, + "og": { + "kicker": "Sponsors", + "title": "Soutenir TablePro" + }, + "breadcrumb": "Sponsors", + "header": { + "title": "Sponsors", + "lead": "TablePro est open source. Les sponsors financent le temps consacré à son développement et à sa maintenance.", + "action": "Sponsoriser sur GitHub" + }, + "companies": { + "title": "Entreprises", + "open": { + "one": "{count} place sur {total} est libre sur la page d’accueil.", + "other": "{count} places sur {total} sont libres sur la page d’accueil." + }, + "full": "Les {total} places de la page d’accueil sont prises." + }, + "people": { + "title": "Particuliers" + }, + "tiers": { + "title": "Niveaux", + "lead": { + "one": "Sponsorisez chaque mois, ou une seule fois pour {count} mois au même emplacement.", + "other": "Sponsorisez chaque mois, ou une seule fois pour {count} mois au même emplacement." + }, + "caption": "Niveaux de parrainage avec le prix mensuel, le prix unique et ce que comprend chacun", + "columns": { + "monthly": "Mensuel", + "oneTime": "Une fois", + "benefit": "Ce que vous obtenez" + }, + "oneTimePrice": "{price} une fois", + "benefit": { + "name": "Votre nom et un lien sur cette page et dans le README de TablePro.", + "homepage": "Votre logo et un lien sur la page d’accueil, sur cette page et dans le README de TablePro. La page d’accueil compte {total} places.", + "lead": "Votre logo en premier et en plus grand sur la page d’accueil, cette page et le README." + }, + "link": "Sponsoriser TablePro sur GitHub" + } +} diff --git a/resources/data/content/id/about.json b/resources/data/content/id/about.json index dc4bd534..b7260913 100644 --- a/resources/data/content/id/about.json +++ b/resources/data/content/id/about.json @@ -28,7 +28,7 @@ "title": "Pendanaan", "body": "Lisensi berbayar dan sponsor mendanai TablePro. Proyek ini tidak memiliki investor dan tidak menjual data.", "plans": "Lisensi Starter atau Team menambahkan fitur opsional ke aplikasi Mac; halaman harga mencantumkannya.", - "sponsors": "Sponsor saat ini tercantum di beranda. Anda dapat menjadi sponsor melalui GitHub Sponsors." + "sponsors": "Sponsor saat ini dan isi setiap tingkatan ada di halaman sponsor." }, "source": { "title": "Kode sumber", diff --git a/resources/data/content/id/home.json b/resources/data/content/id/home.json index f0b282b2..57e7faaf 100644 --- a/resources/data/content/id/home.json +++ b/resources/data/content/id/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsor", - "link": "Sponsori TablePro" + "link": "Sponsori TablePro", + "invite": { + "title": "Logo Anda di sini", + "open": { + "one": "{count} dari {total} tersedia", + "other": "{count} dari {total} tersedia" + } + } }, "workflows": { "title": "Bekerja dengan database Anda", diff --git a/resources/data/content/id/sponsors.json b/resources/data/content/id/sponsors.json new file mode 100644 index 00000000..8138fa48 --- /dev/null +++ b/resources/data/content/id/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsor", + "description": "Perusahaan dan orang yang mensponsori TablePro, isi setiap tingkatan sponsor, dan cara menjadi sponsor melalui GitHub Sponsors." + }, + "og": { + "kicker": "Sponsor", + "title": "Sponsori TablePro" + }, + "breadcrumb": "Sponsor", + "header": { + "title": "Sponsor", + "lead": "TablePro adalah open source. Sponsor membayar waktu yang dicurahkan untuk mengembangkan dan merawatnya.", + "action": "Sponsori di GitHub" + }, + "companies": { + "title": "Perusahaan", + "open": { + "one": "{count} dari {total} tempat di beranda masih tersedia.", + "other": "{count} dari {total} tempat di beranda masih tersedia." + }, + "full": "Semua {total} tempat di beranda sudah terisi." + }, + "people": { + "title": "Perorangan" + }, + "tiers": { + "title": "Tingkatan", + "lead": { + "one": "Sponsori setiap bulan, atau sekali untuk {count} bulan dengan penempatan yang sama.", + "other": "Sponsori setiap bulan, atau sekali untuk {count} bulan dengan penempatan yang sama." + }, + "caption": "Tingkatan sponsor dengan harga bulanan, harga sekali bayar, dan isi setiap tingkatan", + "columns": { + "monthly": "Bulanan", + "oneTime": "Sekali", + "benefit": "Yang Anda dapatkan" + }, + "oneTimePrice": "{price} sekali", + "benefit": { + "name": "Nama dan tautan Anda di halaman ini dan di README TablePro.", + "homepage": "Logo dan tautan Anda di beranda, halaman ini, dan README TablePro. Beranda memuat {total} tempat.", + "lead": "Logo Anda paling depan dan lebih besar di beranda, halaman ini, dan README." + }, + "link": "Sponsori TablePro di GitHub" + } +} diff --git a/resources/data/content/it/about.json b/resources/data/content/it/about.json index 1a6c2fff..fd95e2a8 100644 --- a/resources/data/content/it/about.json +++ b/resources/data/content/it/about.json @@ -28,7 +28,7 @@ "title": "Finanziamento", "body": "Le licenze a pagamento e gli sponsor finanziano TablePro. Il progetto non ha investitori e non vende dati.", "plans": "Una licenza Starter o Team aggiunge funzionalità opzionali all’app per Mac; la pagina dei prezzi le elenca.", - "sponsors": "Gli sponsor attuali sono elencati nella home page. Le sponsorizzazioni passano da GitHub Sponsors." + "sponsors": "Gli sponsor attuali e cosa include ogni livello sono nella pagina degli sponsor." }, "source": { "title": "Codice sorgente", diff --git a/resources/data/content/it/home.json b/resources/data/content/it/home.json index 4accf253..fbb3fb38 100644 --- a/resources/data/content/it/home.json +++ b/resources/data/content/it/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsor", - "link": "Sostieni TablePro" + "link": "Sostieni TablePro", + "invite": { + "title": "Il tuo logo qui", + "open": { + "one": "{count} posto libero su {total}", + "other": "{count} posti liberi su {total}" + } + } }, "workflows": { "title": "Lavorare con i database", diff --git a/resources/data/content/it/sponsors.json b/resources/data/content/it/sponsors.json new file mode 100644 index 00000000..9db9d611 --- /dev/null +++ b/resources/data/content/it/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsor", + "description": "Le aziende e le persone che sponsorizzano TablePro, cosa include ogni livello di sponsorizzazione e come diventare sponsor tramite GitHub Sponsors." + }, + "og": { + "kicker": "Sponsor", + "title": "Sostieni TablePro" + }, + "breadcrumb": "Sponsor", + "header": { + "title": "Sponsor", + "lead": "TablePro è open source. Gli sponsor pagano il tempo dedicato a svilupparlo e mantenerlo.", + "action": "Sponsorizza su GitHub" + }, + "companies": { + "title": "Aziende", + "open": { + "one": "{count} posto su {total} è libero nella home page.", + "other": "{count} posti su {total} sono liberi nella home page." + }, + "full": "Tutti i {total} posti nella home page sono occupati." + }, + "people": { + "title": "Persone" + }, + "tiers": { + "title": "Livelli", + "lead": { + "one": "Sponsorizza ogni mese, oppure una volta sola per {count} mese con lo stesso posizionamento.", + "other": "Sponsorizza ogni mese, oppure una volta sola per {count} mesi con lo stesso posizionamento." + }, + "caption": "Livelli di sponsorizzazione con il prezzo mensile, il prezzo una tantum e cosa include ciascuno", + "columns": { + "monthly": "Mensile", + "oneTime": "Una tantum", + "benefit": "Cosa include" + }, + "oneTimePrice": "{price} una tantum", + "benefit": { + "name": "Il tuo nome con un link in questa pagina e nel README di TablePro.", + "homepage": "Il tuo logo con un link nella home page, in questa pagina e nel README di TablePro. La home page ha {total} posti.", + "lead": "Il tuo logo per primo e più grande nella home page, in questa pagina e nel README." + }, + "link": "Sponsorizza TablePro su GitHub" + } +} diff --git a/resources/data/content/ja/about.json b/resources/data/content/ja/about.json index c18a7e43..43dcd7e1 100644 --- a/resources/data/content/ja/about.json +++ b/resources/data/content/ja/about.json @@ -28,7 +28,7 @@ "title": "開発資金", "body": "TablePro は有料ライセンスとスポンサーの支援で開発されています。投資家はおらず、データの販売もしません。", "plans": "Starter または Team ライセンスで Mac アプリに任意の機能を追加できます。料金ページに一覧があります。", - "sponsors": "現在のスポンサーはトップページに掲載されています。スポンサーには GitHub Sponsors から登録できます。" + "sponsors": "現在のスポンサーと各ティアの内容はスポンサーページに掲載しています。" }, "source": { "title": "ソースコード", diff --git a/resources/data/content/ja/home.json b/resources/data/content/ja/home.json index 06b25ab8..e27b5022 100644 --- a/resources/data/content/ja/home.json +++ b/resources/data/content/ja/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "スポンサー", - "link": "TablePro を支援" + "link": "TablePro を支援", + "invite": { + "title": "ここにロゴを掲載", + "open": { + "one": "空き {count} / {total} 枠", + "other": "空き {count} / {total} 枠" + } + } }, "workflows": { "title": "データベースでできること", diff --git a/resources/data/content/ja/sponsors.json b/resources/data/content/ja/sponsors.json new file mode 100644 index 00000000..b8692c7a --- /dev/null +++ b/resources/data/content/ja/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "スポンサー", + "description": "TablePro を支援する企業と個人、各スポンサーティアの内容、GitHub Sponsors でスポンサーになる方法を紹介します。" + }, + "og": { + "kicker": "スポンサー", + "title": "TablePro を支援" + }, + "breadcrumb": "スポンサー", + "header": { + "title": "スポンサー", + "lead": "TablePro はオープンソースです。スポンサーの支援が、開発と保守に費やす時間を支えています。", + "action": "GitHub でスポンサーになる" + }, + "companies": { + "title": "企業", + "open": { + "one": "トップページの {total} 枠のうち {count} 枠が空いています。", + "other": "トップページの {total} 枠のうち {count} 枠が空いています。" + }, + "full": "トップページの {total} 枠はすべて埋まっています。" + }, + "people": { + "title": "個人" + }, + "tiers": { + "title": "ティア", + "lead": { + "one": "毎月のスポンサーのほか、一括払いで同じ掲載を {count} か月受けられます。", + "other": "毎月のスポンサーのほか、一括払いで同じ掲載を {count} か月受けられます。" + }, + "caption": "スポンサーティアの月額、一括払いの金額、各ティアの内容", + "columns": { + "monthly": "月額", + "oneTime": "一括", + "benefit": "内容" + }, + "oneTimePrice": "一括 {price}", + "benefit": { + "name": "このページと TablePro の README に名前とリンクを掲載。", + "homepage": "トップページ、このページ、TablePro の README にロゴとリンクを掲載。トップページの掲載枠は {total} 枠です。", + "lead": "トップページ、このページ、README でロゴを先頭に大きく掲載。" + }, + "link": "GitHub で TablePro を支援" + } +} diff --git a/resources/data/content/ko/about.json b/resources/data/content/ko/about.json index 0a84962f..31a7df3d 100644 --- a/resources/data/content/ko/about.json +++ b/resources/data/content/ko/about.json @@ -28,7 +28,7 @@ "title": "개발 자금", "body": "TablePro는 유료 라이선스와 후원으로 운영됩니다. 투자자가 없으며 데이터를 판매하지 않습니다.", "plans": "Starter 또는 Team 라이선스는 Mac 앱에 선택 기능을 추가하며 요금 페이지에 목록이 있습니다.", - "sponsors": "현재 후원자는 홈페이지에서 볼 수 있습니다. 후원은 GitHub Sponsors를 통해 이루어집니다." + "sponsors": "현재 후원자와 각 등급의 혜택은 후원 페이지에서 볼 수 있습니다." }, "source": { "title": "소스 코드", diff --git a/resources/data/content/ko/home.json b/resources/data/content/ko/home.json index 11dfc2a9..12a000a5 100644 --- a/resources/data/content/ko/home.json +++ b/resources/data/content/ko/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "후원자", - "link": "TablePro 후원" + "link": "TablePro 후원", + "invite": { + "title": "여기에 로고를 게재하세요", + "open": { + "one": "{total}개 중 {count}개 남음", + "other": "{total}개 중 {count}개 남음" + } + } }, "workflows": { "title": "데이터베이스 작업", diff --git a/resources/data/content/ko/sponsors.json b/resources/data/content/ko/sponsors.json new file mode 100644 index 00000000..23ff33a2 --- /dev/null +++ b/resources/data/content/ko/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "후원자", + "description": "TablePro를 후원하는 기업과 개인, 후원 등급별 혜택, GitHub Sponsors로 후원하는 방법을 안내합니다." + }, + "og": { + "kicker": "후원자", + "title": "TablePro 후원" + }, + "breadcrumb": "후원자", + "header": { + "title": "후원자", + "lead": "TablePro는 오픈 소스입니다. 후원금은 개발과 유지 보수에 드는 시간을 지원합니다.", + "action": "GitHub에서 후원하기" + }, + "companies": { + "title": "기업", + "open": { + "one": "홈페이지의 {total}개 자리 중 {count}개가 비어 있습니다.", + "other": "홈페이지의 {total}개 자리 중 {count}개가 비어 있습니다." + }, + "full": "홈페이지의 {total}개 자리가 모두 찼습니다." + }, + "people": { + "title": "개인" + }, + "tiers": { + "title": "후원 등급", + "lead": { + "one": "매월 후원하거나, 한 번 결제로 같은 혜택을 {count}개월 동안 받을 수 있습니다.", + "other": "매월 후원하거나, 한 번 결제로 같은 혜택을 {count}개월 동안 받을 수 있습니다." + }, + "caption": "후원 등급별 월 금액, 일시불 금액, 혜택", + "columns": { + "monthly": "월", + "oneTime": "일시불", + "benefit": "혜택" + }, + "oneTimePrice": "일시불 {price}", + "benefit": { + "name": "이 페이지와 TablePro README에 이름과 링크를 게재합니다.", + "homepage": "홈페이지, 이 페이지, TablePro README에 로고와 링크를 게재합니다. 홈페이지에는 {total}개 자리가 있습니다.", + "lead": "홈페이지, 이 페이지, README에서 로고를 맨 앞에 더 크게 게재합니다." + }, + "link": "GitHub에서 TablePro 후원하기" + } +} diff --git a/resources/data/content/pt-BR/about.json b/resources/data/content/pt-BR/about.json index ad6b6fbc..af7c8662 100644 --- a/resources/data/content/pt-BR/about.json +++ b/resources/data/content/pt-BR/about.json @@ -28,7 +28,7 @@ "title": "Financiamento", "body": "Licenças pagas e patrocinadores financiam o TablePro. O projeto não tem investidores e não vende dados.", "plans": "Uma licença Starter ou Team adiciona recursos opcionais ao app para Mac; a página de preços os lista.", - "sponsors": "Os patrocinadores atuais aparecem na página inicial. O patrocínio é feito pelo GitHub Sponsors." + "sponsors": "Os patrocinadores atuais e o que cada nível inclui estão na página de patrocinadores." }, "source": { "title": "Código-fonte", diff --git a/resources/data/content/pt-BR/home.json b/resources/data/content/pt-BR/home.json index f8a2bd79..ab0b6e5d 100644 --- a/resources/data/content/pt-BR/home.json +++ b/resources/data/content/pt-BR/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Patrocinadores", - "link": "Patrocinar TablePro" + "link": "Patrocinar TablePro", + "invite": { + "title": "Seu logo aqui", + "open": { + "one": "{count} de {total} livre", + "other": "{count} de {total} livres" + } + } }, "workflows": { "title": "Trabalhando com seus bancos de dados", diff --git a/resources/data/content/pt-BR/sponsors.json b/resources/data/content/pt-BR/sponsors.json new file mode 100644 index 00000000..cb9807b1 --- /dev/null +++ b/resources/data/content/pt-BR/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Patrocinadores", + "description": "As empresas e pessoas que patrocinam TablePro, o que cada nível de patrocínio inclui e como patrocinar pelo GitHub Sponsors." + }, + "og": { + "kicker": "Patrocinadores", + "title": "Patrocinar TablePro" + }, + "breadcrumb": "Patrocinadores", + "header": { + "title": "Patrocinadores", + "lead": "TablePro é de código aberto. Os patrocinadores pagam o tempo dedicado ao desenvolvimento e à manutenção.", + "action": "Patrocinar no GitHub" + }, + "companies": { + "title": "Empresas", + "open": { + "one": "{count} de {total} espaços na página inicial está livre.", + "other": "{count} de {total} espaços na página inicial estão livres." + }, + "full": "Os {total} espaços da página inicial estão ocupados." + }, + "people": { + "title": "Pessoas" + }, + "tiers": { + "title": "Níveis", + "lead": { + "one": "Patrocine todo mês, ou uma única vez por {count} mês com o mesmo posicionamento.", + "other": "Patrocine todo mês, ou uma única vez por {count} meses com o mesmo posicionamento." + }, + "caption": "Níveis de patrocínio com o preço mensal, o preço único e o que cada um inclui", + "columns": { + "monthly": "Mensal", + "oneTime": "Uma vez", + "benefit": "O que inclui" + }, + "oneTimePrice": "{price} uma vez", + "benefit": { + "name": "Seu nome e um link nesta página e no README do TablePro.", + "homepage": "Seu logo e um link na página inicial, nesta página e no README do TablePro. A página inicial tem {total} espaços.", + "lead": "Seu logo primeiro e maior na página inicial, nesta página e no README." + }, + "link": "Patrocinar TablePro no GitHub" + } +} diff --git a/resources/data/content/vi/about.json b/resources/data/content/vi/about.json index e2257268..46665522 100644 --- a/resources/data/content/vi/about.json +++ b/resources/data/content/vi/about.json @@ -28,7 +28,7 @@ "title": "Nguồn kinh phí", "body": "Tiền bán license và tiền tài trợ là nguồn kinh phí của TablePro. TablePro không có nhà đầu tư và không bán dữ liệu.", "plans": "License Starter hoặc Team bổ sung một số tính năng tùy chọn cho ứng dụng Mac; trang bảng giá liệt kê các tính năng này.", - "sponsors": "Các nhà tài trợ hiện tại được liệt kê trên trang chủ. Bạn có thể tài trợ qua GitHub Sponsors." + "sponsors": "Các nhà tài trợ hiện tại và quyền lợi của từng mức tài trợ có trên trang nhà tài trợ." }, "source": { "title": "Mã nguồn", diff --git a/resources/data/content/vi/home.json b/resources/data/content/vi/home.json index 7a0fd788..be25c92b 100644 --- a/resources/data/content/vi/home.json +++ b/resources/data/content/vi/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Nhà tài trợ", - "link": "Tài trợ TablePro" + "link": "Tài trợ TablePro", + "invite": { + "title": "Logo của bạn ở đây", + "open": { + "one": "Còn trống {count}/{total} chỗ", + "other": "Còn trống {count}/{total} chỗ" + } + } }, "workflows": { "title": "Làm việc với cơ sở dữ liệu", diff --git a/resources/data/content/vi/sponsors.json b/resources/data/content/vi/sponsors.json new file mode 100644 index 00000000..aeae4bf4 --- /dev/null +++ b/resources/data/content/vi/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Nhà tài trợ", + "description": "Các công ty và cá nhân tài trợ TablePro, quyền lợi của từng mức tài trợ và cách tài trợ qua GitHub Sponsors." + }, + "og": { + "kicker": "Nhà tài trợ", + "title": "Tài trợ TablePro" + }, + "breadcrumb": "Nhà tài trợ", + "header": { + "title": "Nhà tài trợ", + "lead": "TablePro là phần mềm mã nguồn mở. Tiền tài trợ trả cho thời gian phát triển và bảo trì TablePro.", + "action": "Tài trợ trên GitHub" + }, + "companies": { + "title": "Công ty", + "open": { + "one": "Trang chủ còn trống {count} trong số {total} chỗ.", + "other": "Trang chủ còn trống {count} trong số {total} chỗ." + }, + "full": "Trang chủ đã kín cả {total} chỗ." + }, + "people": { + "title": "Cá nhân" + }, + "tiers": { + "title": "Các mức tài trợ", + "lead": { + "one": "Bạn có thể tài trợ theo tháng, hoặc tài trợ một lần để giữ cùng vị trí trong {count} tháng.", + "other": "Bạn có thể tài trợ theo tháng, hoặc tài trợ một lần để giữ cùng vị trí trong {count} tháng." + }, + "caption": "Các mức tài trợ với giá hằng tháng, giá một lần và quyền lợi của từng mức", + "columns": { + "monthly": "Theo tháng", + "oneTime": "Một lần", + "benefit": "Quyền lợi" + }, + "oneTimePrice": "{price} một lần", + "benefit": { + "name": "Tên và link của bạn trên trang này và trong README của TablePro.", + "homepage": "Logo và link của bạn trên trang chủ, trang này và README của TablePro. Trang chủ có {total} chỗ.", + "lead": "Logo của bạn đứng đầu và lớn hơn trên trang chủ, trang này và README." + }, + "link": "Tài trợ TablePro trên GitHub" + } +} diff --git a/resources/data/content/zh-Hans/about.json b/resources/data/content/zh-Hans/about.json index e168aefb..37d546b3 100644 --- a/resources/data/content/zh-Hans/about.json +++ b/resources/data/content/zh-Hans/about.json @@ -28,7 +28,7 @@ "title": "资金来源", "body": "TablePro 的资金来自付费许可证和赞助商。TablePro 没有投资者,也不出售数据。", "plans": "Starter 或 Team 许可证可为 Mac 应用增加可选功能,定价页面列出了这些功能。", - "sponsors": "当前的赞助商列在首页上。赞助通过 GitHub Sponsors 进行。" + "sponsors": "当前的赞助商和各档位的权益列在赞助页面上。" }, "source": { "title": "源代码", diff --git a/resources/data/content/zh-Hans/home.json b/resources/data/content/zh-Hans/home.json index 37e6ea93..0c9207a0 100644 --- a/resources/data/content/zh-Hans/home.json +++ b/resources/data/content/zh-Hans/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "赞助商", - "link": "赞助 TablePro" + "link": "赞助 TablePro", + "invite": { + "title": "在此展示徽标", + "open": { + "one": "剩余 {count}/{total} 个位置", + "other": "剩余 {count}/{total} 个位置" + } + } }, "workflows": { "title": "数据库操作", diff --git a/resources/data/content/zh-Hans/sponsors.json b/resources/data/content/zh-Hans/sponsors.json new file mode 100644 index 00000000..1e9b2674 --- /dev/null +++ b/resources/data/content/zh-Hans/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "赞助商", + "description": "赞助 TablePro 的公司和个人、各赞助档位的权益,以及如何通过 GitHub Sponsors 成为赞助商。" + }, + "og": { + "kicker": "赞助商", + "title": "赞助 TablePro" + }, + "breadcrumb": "赞助商", + "header": { + "title": "赞助商", + "lead": "TablePro 是开源软件。赞助用于支付开发和维护所投入的时间。", + "action": "在 GitHub 上赞助" + }, + "companies": { + "title": "公司", + "open": { + "one": "首页共 {total} 个位置,还剩 {count} 个。", + "other": "首页共 {total} 个位置,还剩 {count} 个。" + }, + "full": "首页的 {total} 个位置已全部占满。" + }, + "people": { + "title": "个人" + }, + "tiers": { + "title": "赞助档位", + "lead": { + "one": "可以按月赞助,也可以一次性付款,在 {count} 个月内获得相同的展示位置。", + "other": "可以按月赞助,也可以一次性付款,在 {count} 个月内获得相同的展示位置。" + }, + "caption": "各赞助档位的月付价格、一次性价格和权益", + "columns": { + "monthly": "月付", + "oneTime": "一次性", + "benefit": "权益" + }, + "oneTimePrice": "一次性 {price}", + "benefit": { + "name": "在本页和 TablePro 的 README 中展示名字和链接。", + "homepage": "在首页、本页和 TablePro 的 README 中展示徽标和链接。首页共 {total} 个位置。", + "lead": "在首页、本页和 README 中,徽标排在最前并以更大尺寸展示。" + }, + "link": "在 GitHub 上赞助 TablePro" + } +} diff --git a/resources/data/content/zh-Hant/about.json b/resources/data/content/zh-Hant/about.json index 2f97f756..46028c30 100644 --- a/resources/data/content/zh-Hant/about.json +++ b/resources/data/content/zh-Hant/about.json @@ -28,7 +28,7 @@ "title": "資金來源", "body": "TablePro 的資金來自付費授權與贊助者。TablePro 沒有投資者,也不出售資料。", "plans": "Starter 或 Team 授權可為 Mac App 增加選用功能,價格方案頁面列出了這些功能。", - "sponsors": "目前的贊助者列在首頁上。贊助透過 GitHub Sponsors 進行。" + "sponsors": "目前的贊助者和各級別的權益列在贊助頁面上。" }, "source": { "title": "原始碼", diff --git a/resources/data/content/zh-Hant/home.json b/resources/data/content/zh-Hant/home.json index 88f88dc2..f825f3e6 100644 --- a/resources/data/content/zh-Hant/home.json +++ b/resources/data/content/zh-Hant/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "贊助者", - "link": "贊助 TablePro" + "link": "贊助 TablePro", + "invite": { + "title": "在此展示標誌", + "open": { + "one": "剩餘 {count}/{total} 個位置", + "other": "剩餘 {count}/{total} 個位置" + } + } }, "workflows": { "title": "資料庫操作", diff --git a/resources/data/content/zh-Hant/sponsors.json b/resources/data/content/zh-Hant/sponsors.json new file mode 100644 index 00000000..6cad36a7 --- /dev/null +++ b/resources/data/content/zh-Hant/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "贊助者", + "description": "贊助 TablePro 的公司和個人、各贊助級別的權益,以及如何透過 GitHub Sponsors 成為贊助者。" + }, + "og": { + "kicker": "贊助者", + "title": "贊助 TablePro" + }, + "breadcrumb": "贊助者", + "header": { + "title": "贊助者", + "lead": "TablePro 是開源軟體。贊助用於支付開發與維護所投入的時間。", + "action": "在 GitHub 上贊助" + }, + "companies": { + "title": "公司", + "open": { + "one": "首頁共 {total} 個位置,還剩 {count} 個。", + "other": "首頁共 {total} 個位置,還剩 {count} 個。" + }, + "full": "首頁的 {total} 個位置已全部額滿。" + }, + "people": { + "title": "個人" + }, + "tiers": { + "title": "贊助級別", + "lead": { + "one": "可以按月贊助,也可以一次付款,在 {count} 個月內享有相同的展示位置。", + "other": "可以按月贊助,也可以一次付款,在 {count} 個月內享有相同的展示位置。" + }, + "caption": "各贊助級別的月付價格、一次性價格與權益", + "columns": { + "monthly": "月付", + "oneTime": "一次性", + "benefit": "權益" + }, + "oneTimePrice": "一次性 {price}", + "benefit": { + "name": "在本頁和 TablePro 的 README 中展示名字和連結。", + "homepage": "在首頁、本頁和 TablePro 的 README 中展示標誌和連結。首頁共 {total} 個位置。", + "lead": "在首頁、本頁和 README 中,標誌排在最前並以更大尺寸展示。" + }, + "link": "在 GitHub 上贊助 TablePro" + } +} diff --git a/resources/data/sponsors.json b/resources/data/sponsors.json index 5d65ab89..4d1c2917 100644 --- a/resources/data/sponsors.json +++ b/resources/data/sponsors.json @@ -1,14 +1,23 @@ { - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-29", "verification": { - "method": "GitHub GraphQL sponsorshipsAsMaintainer(activeOnly: true) for the user datlechin and the organization TableProApp", + "method": "GitHub GraphQL sponsorshipsAsMaintainer(activeOnly: true, includePrivate: true) for the user datlechin and the organization TableProApp, read with the sponsors skill", "program": "https://github.com/sponsors/datlechin" }, + "homepageSlots": 12, + "leadSlots": 1, + "oneTime": { "multiplier": 10, "months": 12 }, + "tiers": [ + { "placement": "name", "monthly": 10 }, + { "placement": "homepage", "monthly": 100 }, + { "placement": "lead", "monthly": 250 } + ], "sponsors": [ { "id": "coderabbit", "name": "CodeRabbit", "githubLogin": "coderabbitai", + "placement": "homepage", "url": "https://coderabbit.link/tablepro", "logo": { "light": "/images/sponsors/coderabbit.svg", "dark": null, "width": 2152, "height": 313 } }, @@ -16,6 +25,7 @@ "id": "simplelocalize", "name": "SimpleLocalize", "githubLogin": "simplelocalize", + "placement": "homepage", "url": "https://simplelocalize.io?ref=tablepro", "logo": { "light": "/images/sponsors/simplelocalize.svg", "dark": null, "width": 540, "height": 101 } }, @@ -23,6 +33,7 @@ "id": "nimbus", "name": "Nimbus", "githubLogin": "getnimbus", + "placement": "homepage", "url": "https://getnimbus.io?ref=tablepro", "logo": { "light": "/images/sponsors/nimbus.svg", "dark": null, "width": 430, "height": 168 } }, @@ -30,8 +41,25 @@ "id": "dwarves-foundation", "name": "Dwarves Foundation", "githubLogin": "dwarvesf", + "placement": "homepage", "url": "https://dwarves.foundation?ref=tablepro", "logo": { "light": "/images/sponsors/dwarves-foundation.png", "dark": null, "width": 112, "height": 112 } + }, + { + "id": "pyto", + "name": "Pyto", + "githubLogin": "withpyto", + "placement": "homepage", + "url": "https://pyto.com?ref=tablepro", + "logo": { "light": "/images/sponsors/pyto.svg", "dark": null, "width": 146, "height": 43 } + }, + { + "id": "zhuwjin", + "name": "朱文锦", + "githubLogin": "zhuwjin", + "placement": "name", + "url": "https://github.com/zhuwjin", + "logo": null } ] } diff --git a/resources/js/components/faq/content-links.tsx b/resources/js/components/faq/content-links.tsx index 00777bab..2a0662a1 100644 --- a/resources/js/components/faq/content-links.tsx +++ b/resources/js/components/faq/content-links.tsx @@ -31,7 +31,7 @@ export interface SiteLinks { */ const INTERNAL: Record = { home: '/', - sponsors: '/#sponsors', + sponsors: '/sponsors', features: '/features', querying: '/features/querying', connections: '/features/connections', diff --git a/resources/js/components/home/sponsors-section.tsx b/resources/js/components/home/sponsors-section.tsx index 0c4136de..2c14de74 100644 --- a/resources/js/components/home/sponsors-section.tsx +++ b/resources/js/components/home/sponsors-section.tsx @@ -1,62 +1,17 @@ -import CellGrid from '@/components/ui/cell-grid'; +import SponsorGrid, { SPONSOR_CELL, SPONSOR_LINK } from '@/components/sponsors/sponsor-grid'; +import LocaleLink from '@/components/ui/locale-link'; import Section from '@/components/ui/section'; -import TextLink from '@/components/ui/text-link'; -import { FACTS } from '@/lib/data/facts'; -import { SPONSORS, type Sponsor } from '@/lib/data/sponsors'; +import { textLinkClasses } from '@/components/ui/text-link'; +import { useI18n } from '@/i18n'; +import { SPONSORS } from '@/lib/data/sponsors'; +import { onHomepage, openSlots } from '@/lib/sponsors'; import type { HomeContent } from './types'; -/** - * Logos are drawn 28px tall (design-system §5.3.18 SponsorList); the width - * follows the file's own ratio. Two columns below 640px (a 2 × 2 grid, §8.1), - * a wrapping row above, and a wordmark wider than its column scales down - * (`max-w-full`, `object-contain`) rather than running into its neighbour. - * - * Loaded eagerly at low priority: the four files are under 35 KB together, - * and a lazy logo that arrived late left its white dark-mode tile empty, which - * read as a broken image. Low priority keeps them behind the page's own - * images, and keeps React's server render from preloading them. - */ -const LOGO_HEIGHT = 28; - -function SponsorLogo({ sponsor }: { sponsor: Sponsor }) { - const width = Math.round((LOGO_HEIGHT * sponsor.logo.width) / sponsor.logo.height); - const image = (src: string, className: string) => ( - - ); - - /* - * A sponsor's own dark logo when the data has one. Otherwise the regular - * logo sits on a white tile in dark mode, as the database marks do: no - * filter or inversion ever alters a trademark. The tile's padding is there - * in light mode too, so switching theme moves nothing. - */ - return ( - - ); -} - -/** - * Section 3, `#sponsors`. Third on the page by the owner's decision (spec §0), - * and compact, so it interrupts the explanation as little as possible. - * - * Only the sponsors verified as current are listed (resources/data/sponsors.json - * records when and how). Each link is `rel="sponsored noopener"` and named by - * the sponsor's visible name under its logo. - * - * The logos are a wall of cells that closes on the next join (design-system - * §4.7): two across on a phone, four from 768px. A fifth sponsor starts a - * second row and leaves the rest of it blank page, not a block of line colour. - */ +// Third on the page by the owner's decision, and compact, so it interrupts the explanation as little as possible. export default function SponsorsSection({ content }: { content: HomeContent['sponsors'] }) { + const { plural } = useI18n(); + const open = openSlots(SPONSORS); + return (
+ {content.link} - + + } > - - {SPONSORS.sponsors.map((sponsor) => ( -
  • - - - - {sponsor.name} + + {open > 0 && ( +
  • + + + {content.invite.title} + + + {plural(content.invite.open, open, { total: SPONSORS.homepageSlots })} - +
  • - ))} -
    + )} +
    ); } diff --git a/resources/js/components/site/site-footer.tsx b/resources/js/components/site/site-footer.tsx index 6939fb0a..9cc8448a 100644 --- a/resources/js/components/site/site-footer.tsx +++ b/resources/js/components/site/site-footer.tsx @@ -307,7 +307,9 @@ export default function SiteFooter({ newsletter = true }: { newsletter?: boolean )}
  • - {groups.community.sponsor} + + {groups.community.sponsor} +
  • diff --git a/resources/js/components/site/site-links.ts b/resources/js/components/site/site-links.ts index bc0d36ff..43314dea 100644 --- a/resources/js/components/site/site-links.ts +++ b/resources/js/components/site/site-links.ts @@ -75,7 +75,6 @@ export const EXTERNAL = { github: facts.links.github, issues: facts.links.issues, discussions: facts.links.discussions, - sponsors: facts.links.sponsorsProgram, discord: facts.links.discord, x: facts.links.x, telegram: facts.links.telegram, diff --git a/resources/js/components/sponsors/sponsor-grid.tsx b/resources/js/components/sponsors/sponsor-grid.tsx new file mode 100644 index 00000000..3d0a8db9 --- /dev/null +++ b/resources/js/components/sponsors/sponsor-grid.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import CellGrid from '@/components/ui/cell-grid'; +import type { LogoSponsor, SponsorLogo } from '@/lib/data/sponsors'; +import { cn } from '@/lib/utils'; + +// tests/Feature/Data/SponsorsDataTest.php reads both heights to keep raster logos near the size they are drawn at. +const LOGO_HEIGHT = 28; +const LEAD_LOGO_HEIGHT = 44; + +// Eager at low priority: a lazy logo that arrived late left its white dark-mode tile empty, which read as a broken image. +function Logo({ logo, lead }: { logo: SponsorLogo; lead: boolean }) { + const height = lead ? LEAD_LOGO_HEIGHT : LOGO_HEIGHT; + const width = Math.round((height * logo.width) / logo.height); + const image = (src: string, className: string) => ( + + ); + + // No filter ever alters a trademark: without its own dark file, a logo sits on a white tile, padded in both themes so nothing moves. + return ( + + ); +} + +// Shared with the homepage's invite cell, so it lines up with the logos. +export const SPONSOR_CELL = 'min-w-0 max-w-full'; +export const SPONSOR_LINK = 'group grid max-w-full justify-items-start gap-2 rounded-control'; + +// Two across on a phone, four from 768px. The lead spans two cells; trailing children (an invite) share the grid's lines. +export default function SponsorGrid({ sponsors, children }: { sponsors: LogoSponsor[]; children?: ReactNode }) { + return ( + + {sponsors.map((sponsor) => { + const lead = sponsor.placement === 'lead'; + + return ( +
  • + + + + {sponsor.name} + + +
  • + ); + })} + {children} +
    + ); +} diff --git a/resources/js/lib/attribution.ts b/resources/js/lib/attribution.ts index 9b582b6a..951360c4 100644 --- a/resources/js/lib/attribution.ts +++ b/resources/js/lib/attribution.ts @@ -144,7 +144,7 @@ export function readVisit(visit: Visit): Attribution | null { /* * `ref` as a fallback for `utm_source`, because that is the shape the - * neighbourhood uses: every link in `sponsor-row.tsx` goes out as + * neighbourhood uses: the sponsor links in `sponsors.json` go out as * `?ref=tablepro`, and the sites carrying a link back send it the same way. * Only consulted when there is no utm_source, so a properly tagged link is * never second-guessed. diff --git a/resources/js/lib/data/sponsors.ts b/resources/js/lib/data/sponsors.ts index 66d13d69..808f738c 100644 --- a/resources/js/lib/data/sponsors.ts +++ b/resources/js/lib/data/sponsors.ts @@ -1,38 +1,40 @@ -/** - * resources/data/sponsors.json, typed: the sponsors verified as current on - * `verifiedAt`, in display order. - * - * The homepage renders them third, each linked with `rel="sponsored noopener"`. - * A sponsor stays listed only while its sponsorship is active; the file - * records how that was checked. - */ import data from '@data/sponsors.json'; +export type Placement = 'lead' | 'homepage' | 'name'; + export interface SponsorLogo { - /** A file under public/, used in both themes unless `dark` is set. */ + // A file under public/, used in both themes unless `dark` is set. light: string; dark: string | null; - /** Intrinsic size, so the row does not reflow while the image loads. */ width: number; height: number; } -export interface Sponsor { +interface SponsorBase { id: string; name: string; githubLogin: string; url: string; - logo: SponsorLogo; +} + +export type Sponsor = SponsorBase & ({ placement: 'name'; logo: null } | { placement: Exclude; logo: SponsorLogo }); + +export type LogoSponsor = Extract; + +export interface SponsorTier { + placement: Placement; + monthly: number; } export interface SponsorsData { verifiedAt: string; verification: { method: string; program: string }; + homepageSlots: number; + leadSlots: number; + oneTime: { multiplier: number; months: number }; + tiers: SponsorTier[]; sponsors: Sponsor[]; } -/** - * The single cast from the JSON import. `tests/Feature/Data/SponsorsDataTest.php` - * validates the file. - */ +// tests/Feature/Data/SponsorsDataTest.php validates the file this casts. export const SPONSORS = data as SponsorsData; diff --git a/resources/js/lib/sponsors.ts b/resources/js/lib/sponsors.ts new file mode 100644 index 00000000..ae8271ab --- /dev/null +++ b/resources/js/lib/sponsors.ts @@ -0,0 +1,20 @@ +import type { LogoSponsor, Placement, Sponsor, SponsorsData } from './data/sponsors.ts'; + +// No JSON import, so `node --test` loads this file directly (tests/js/sponsors.test.ts). + +function placed(data: SponsorsData, placements: Placement[]): Sponsor[] { + return placements.flatMap((placement) => data.sponsors.filter((sponsor) => sponsor.placement === placement)); +} + +export function onHomepage(data: SponsorsData): LogoSponsor[] { + return placed(data, ['lead', 'homepage']) as LogoSponsor[]; +} + +export function byName(data: SponsorsData): Sponsor[] { + return placed(data, ['name']); +} + +// Clamped, so a file with too many homepage sponsors still shows every logo it paid for. +export function openSlots(data: SponsorsData): number { + return Math.max(0, data.homepageSlots - onHomepage(data).length); +} diff --git a/resources/js/pages/Sponsors.tsx b/resources/js/pages/Sponsors.tsx new file mode 100644 index 00000000..e3854b5d --- /dev/null +++ b/resources/js/pages/Sponsors.tsx @@ -0,0 +1,137 @@ +import { usePage } from '@inertiajs/react'; +import { plainPageJsonLd } from '@/components/faq/structured-data'; +import SEOHead from '@/components/seo/seo-head'; +import SponsorGrid from '@/components/sponsors/sponsor-grid'; +import Button from '@/components/ui/button'; +import DataTable, { TABLE_CELL, TABLE_HEAD_CELL, TABLE_ROW, TABLE_ROW_HEADER } from '@/components/ui/data-table'; +import PageHeader from '@/components/ui/page-header'; +import Section from '@/components/ui/section'; +import TextLink, { textLinkClasses } from '@/components/ui/text-link'; +import { LOCALES, useI18n } from '@/i18n'; +import { FACTS, PUBLISHER } from '@/lib/data/facts'; +import { SPONSORS } from '@/lib/data/sponsors'; +import { byName, onHomepage, openSlots } from '@/lib/sponsors'; +import { cn } from '@/lib/utils'; +import LandingLayout from '@/layouts/landing-layout'; + +type SponsorsContent = typeof import('@data/content/en/sponsors.json'); + +interface SponsorsProps { + content: SponsorsContent; + organizationProfiles: string[]; +} + +// The price columns shrink to their content, so the benefit takes the rest of the row. +const PRICE = 'w-px whitespace-nowrap tabular-nums'; + +// No amounts per sponsor, no totals and no goal: what each placement costs is the tiers table. +export default function Sponsors({ content, organizationProfiles }: SponsorsProps) { + const { canonicalBaseUrl } = usePage().props; + const { locale, m, fmt, plural, path, format } = useI18n(); + const companies = onHomepage(SPONSORS); + const people = byName(SPONSORS); + const open = openSlots(SPONSORS); + const total = SPONSORS.homepageSlots; + const usd = (amount: number): string => format.formatUsd(amount, m.pricing.currency); + + const crumbs = [ + { name: m.common.home, path: path('/') }, + { name: content.breadcrumb, path: path('/sponsors') }, + ]; + + return ( + + + + + {content.header.action} + + + } + /> + +
    0 ? plural(content.companies.open, open, { total }) : fmt(content.companies.full, { total })} + flush={companies.length > 0} + > + {companies.length > 0 && } +
    + + {people.length > 0 && ( +
    + +
    + )} + +
    + {/* Below 640px the one-time price folds under the monthly one, in the row header. */} + + + + + {content.tiers.columns.monthly} + + + {content.tiers.columns.oneTime} + + + {content.tiers.columns.benefit} + + + + + {SPONSORS.tiers.map((tier) => { + const once = usd(tier.monthly * SPONSORS.oneTime.multiplier); + + return ( + + + {usd(tier.monthly)} + + {fmt(content.tiers.oneTimePrice, { price: once })} + + + {once} + {fmt(content.tiers.benefit[tier.placement], { total })} + + ); + })} + + +

    + + {content.tiers.link} + +

    +
    +
    + ); +} diff --git a/routes/localized.php b/routes/localized.php index 866b9023..6f12c69f 100644 --- a/routes/localized.php +++ b/routes/localized.php @@ -13,6 +13,7 @@ use App\Http\Controllers\LegalController; use App\Http\Controllers\PricingController; use App\Http\Controllers\SecurityController; +use App\Http\Controllers\SponsorsController; use App\Support\Content\Slugs\CompareSlugs; use App\Support\Content\Slugs\DatabaseSlugs; use App\Support\Content\Slugs\FeatureSlugs; @@ -58,6 +59,7 @@ Route::get('/faq', FaqController::class)->name('landing.faq'); Route::get('/about', AboutController::class)->name('landing.about'); Route::get('/security', SecurityController::class)->name('landing.security'); +Route::get('/sponsors', SponsorsController::class)->name('landing.sponsors'); Route::get('/privacy', [LegalController::class, 'privacy'])->name('landing.privacy'); Route::get('/terms', [LegalController::class, 'terms'])->name('landing.terms'); diff --git a/tests/Feature/Chrome/PageFrameTest.php b/tests/Feature/Chrome/PageFrameTest.php index afa75c76..33164d96 100644 --- a/tests/Feature/Chrome/PageFrameTest.php +++ b/tests/Feature/Chrome/PageFrameTest.php @@ -5,7 +5,7 @@ /** @return array */ function frameTemplates(): array { - $paths = ['/', '/vi', '/pricing', '/download', '/ios', '/faq', '/about', '/security', '/privacy', '/blog', '/blog/tablepro-0-77', '/features', '/features/querying', '/databases', '/postgresql-client', '/compare', '/compare/tableplus']; + $paths = ['/', '/vi', '/pricing', '/download', '/ios', '/faq', '/about', '/security', '/sponsors', '/privacy', '/blog', '/blog/tablepro-0-77', '/features', '/features/querying', '/databases', '/postgresql-client', '/compare', '/compare/tableplus']; return array_combine($paths, array_map(fn(string $path): array => [$path], $paths)); } diff --git a/tests/Feature/Chrome/SiteChromeTest.php b/tests/Feature/Chrome/SiteChromeTest.php index c9984d32..6bc90ae3 100644 --- a/tests/Feature/Chrome/SiteChromeTest.php +++ b/tests/Feature/Chrome/SiteChromeTest.php @@ -233,11 +233,11 @@ function chromeLocales(): array expect($titles[1])->toContain($group); } - foreach (['/features', '/databases', '/integrations', '/ios', '/pricing', '/download', '/compare', '/blog', '/faq', '/about', '/security', '/privacy', '/terms', '/refund-policy'] as $page) { + foreach (['/features', '/databases', '/integrations', '/ios', '/pricing', '/download', '/compare', '/blog', '/faq', '/about', '/sponsors', '/security', '/privacy', '/terms', '/refund-policy'] as $page) { Assert::assertNotNull(chromeLink($footer, $prefix . $page), "The footer has no link to {$prefix}{$page}"); } - foreach (['https://docs.tablepro.app', 'https://docs.tablepro.app/changelog', 'https://github.com/TableProApp/TablePro', 'https://github.com/TableProApp/TablePro/issues', 'https://github.com/sponsors/datlechin', 'mailto:hello@tablepro.app', "/account?locale={$locale}"] as $href) { + foreach (['https://docs.tablepro.app', 'https://docs.tablepro.app/changelog', 'https://github.com/TableProApp/TablePro', 'https://github.com/TableProApp/TablePro/issues', 'mailto:hello@tablepro.app', "/account?locale={$locale}"] as $href) { Assert::assertNotNull(chromeLink($footer, $href), "The footer has no link to {$href}"); } @@ -246,6 +246,9 @@ function chromeLocales(): array Assert::assertNotNull($brand, 'The footer has no link to /brand'); Assert::assertSame($vi ? 'en' : null, $brand['attrs']['hreflang'] ?? null); + // Sponsoring starts on the sponsors page, which shows the tiers before GitHub does. + expect($footer)->not->toContain('github.com/sponsors'); + // The repository is linked once, as "Source code"; Community no longer repeats it. expect(substr_count($footer, 'href="https://github.com/TableProApp/TablePro"'))->toBe(1); diff --git a/tests/Feature/Data/LocalesDataTest.php b/tests/Feature/Data/LocalesDataTest.php index 58f394d1..1779863e 100644 --- a/tests/Feature/Data/LocalesDataTest.php +++ b/tests/Feature/Data/LocalesDataTest.php @@ -47,7 +47,7 @@ function localePrefixes(): array // The platform's paths on this origin (nginx sends them to the account app). 'account', 'checkout', 'webhooks', 'newsletter', 'beta', 'discount', 'thank-you', 'api', 'platform-build', // Static directories under public/. - 'build', 'og', 'images', 'sponsors', + 'build', 'og', 'images', ]) ->unique(); diff --git a/tests/Feature/Data/SponsorsDataTest.php b/tests/Feature/Data/SponsorsDataTest.php index 93dd29f7..dee339e4 100644 --- a/tests/Feature/Data/SponsorsDataTest.php +++ b/tests/Feature/Data/SponsorsDataTest.php @@ -1,12 +1,19 @@ + * homepageSlots: int, + * leadSlots: int, + * oneTime: array{multiplier: int, months: int}, + * tiers: list, + * sponsors: list * } */ function sponsorsJson(): array @@ -39,7 +46,7 @@ function sponsorLogoSize(string $path): ?array it('records when and how the sponsors were verified', function (): void { $data = sponsorsJson(); - expect(array_keys($data))->toBe(['verifiedAt', 'verification', 'sponsors']); + expect(array_keys($data))->toBe(['verifiedAt', 'verification', 'homepageSlots', 'leadSlots', 'oneTime', 'tiers', 'sponsors']); expect($data['verifiedAt'])->toMatch('/^\d{4}-\d{2}-\d{2}$/'); expect($data['verifiedAt'] <= now()->toDateString())->toBeTrue('verifiedAt is in the future'); expect($data['verification']['method'])->toBeString()->not->toBe(''); @@ -49,20 +56,47 @@ function sponsorLogoSize(string $path): ?array expect($data['verification']['program'])->toBe($facts['links']['sponsorsProgram']); }); -it('lists exactly the four verified sponsors, in display order', function (): void { - $sponsors = sponsorsJson()['sponsors']; +it('prices one tier per placement, each above the one before', function (): void { + $data = sponsorsJson(); + + expect(array_column($data['tiers'], 'placement'))->toBe(SPONSOR_PLACEMENTS); + expect($data['homepageSlots'])->toBeInt()->toBeGreaterThan(0); + expect($data['leadSlots'])->toBeInt()->toBeGreaterThan(0)->toBeLessThanOrEqual($data['homepageSlots']); + expect(array_keys($data['oneTime']))->toBe(['multiplier', 'months']); + expect($data['oneTime']['multiplier'])->toBeInt()->toBeGreaterThan(0); + expect($data['oneTime']['months'])->toBeInt()->toBeGreaterThan(0); - expect(array_column($sponsors, 'name'))->toBe(['CodeRabbit', 'SimpleLocalize', 'Nimbus', 'Dwarves Foundation']); - expect(array_column($sponsors, 'githubLogin'))->toBe(['coderabbitai', 'simplelocalize', 'getnimbus', 'dwarvesf']); + $previous = 0; + + foreach ($data['tiers'] as $tier) { + expect(array_keys($tier))->toBe(['placement', 'monthly']); + expect($tier['monthly'])->toBeInt()->toBeGreaterThan($previous); + $previous = $tier['monthly']; + } +}); + +it('places every sponsor once, within the slots, with no amount', function (): void { + $data = sponsorsJson(); + $sponsors = $data['sponsors']; $ids = array_column($sponsors, 'id'); + $logins = array_map('strtolower', array_column($sponsors, 'githubLogin')); expect($ids)->toBe(array_values(array_unique($ids))); + expect($logins)->toBe(array_values(array_unique($logins))); foreach ($sponsors as $sponsor) { - expect(array_keys($sponsor))->toBe(['id', 'name', 'githubLogin', 'url', 'logo']); + // An amount or a tier per sponsor would publish what each one pays. + expect(array_keys($sponsor))->toBe(['id', 'name', 'githubLogin', 'placement', 'url', 'logo']); expect($sponsor['id'])->toMatch('/^[a-z0-9]+(-[a-z0-9]+)*$/'); + expect($sponsor['placement'])->toBeIn(SPONSOR_PLACEMENTS); + expect($sponsor['logo'] === null)->toBe($sponsor['placement'] === 'name', "{$sponsor['name']}: a logo goes with every placement but a name"); } + + $count = fn(array $placements): int => count(array_filter($sponsors, fn(array $sponsor): bool => in_array($sponsor['placement'], $placements, true))); + + expect($count(['lead']))->toBeLessThanOrEqual($data['leadSlots']); + Assert::assertLessThanOrEqual($data['homepageSlots'], $count(['lead', 'homepage']), 'More homepage sponsors than homepage slots'); }); it('links every sponsor over HTTPS', function (): void { @@ -72,27 +106,77 @@ function sponsorLogoSize(string $path): ?array } }); -it('points at logo files that exist, with their real intrinsic size', function (): void { +it('points at logo files named for the sponsor, with their real intrinsic size', function (): void { foreach (sponsorsJson()['sponsors'] as $sponsor) { $logo = $sponsor['logo']; + if ($logo === null) { + continue; + } + expect(array_keys($logo))->toBe(['light', 'dark', 'width', 'height']); + expect($logo['light'])->toMatch("#^/images/sponsors/{$sponsor['id']}\\.(svg|png|webp)$#"); + expect(File::exists(public_path($logo['light'])))->toBeTrue("{$logo['light']} does not exist"); + expect(sponsorLogoSize(public_path($logo['light'])))->toBe([$logo['width'], $logo['height']], "{$sponsor['name']}'s width and height must match the file"); - foreach (array_filter([$logo['light'], $logo['dark']]) as $src) { - expect($src)->toStartWith('/images/sponsors/'); - expect(File::exists(public_path($src)))->toBeTrue("{$src} does not exist"); + if ($logo['dark'] !== null) { + expect($logo['dark'])->toMatch("#^/images/sponsors/{$sponsor['id']}-dark\\.(svg|png|webp)$#"); + expect(File::exists(public_path($logo['dark'])))->toBeTrue("{$logo['dark']} does not exist"); + + // Both files share the declared size, so the dark one must keep the light one's shape. + [$width, $height] = sponsorLogoSize(public_path($logo['dark'])) ?? [0, 1]; + expect(round($width / $height, 2))->toBe(round($logo['width'] / $logo['height'], 2), "{$sponsor['name']}'s dark logo has another aspect ratio"); } + } +}); - expect(sponsorLogoSize(public_path($logo['light'])))->toBe([$logo['width'], $logo['height']], "{$sponsor['name']}'s width and height must match the file"); +it('keeps a raster logo close to the size the grid draws it at', function (): void { + $grid = File::get(resource_path('js/components/sponsors/sponsor-grid.tsx')); + + preg_match('/const LOGO_HEIGHT = (\d+);/', $grid, $regular); + preg_match('/const LEAD_LOGO_HEIGHT = (\d+);/', $grid, $lead); + + expect($regular[1] ?? null)->not->toBeNull(); + expect($lead[1] ?? null)->not->toBeNull(); + + foreach (sponsorsJson()['sponsors'] as $sponsor) { + if ($sponsor['logo'] !== null && ! str_ends_with($sponsor['logo']['light'], '.svg')) { + $shown = (int) ($sponsor['placement'] === 'lead' ? $lead[1] : $regular[1]); + + expect($sponsor['logo']['height'])->toBeLessThanOrEqual(4 * $shown, "{$sponsor['name']}'s logo is far larger than it is shown"); + } } }); -it('keeps a raster logo close to the size the wall shows it at', function (): void { - preg_match('/const LOGO_HEIGHT = (\d+);/', File::get(resource_path('js/components/home/sponsors-section.tsx')), $shown); +it('leaves no logo behind once its sponsor is gone', function (): void { + $referenced = []; foreach (sponsorsJson()['sponsors'] as $sponsor) { - if (! str_ends_with($sponsor['logo']['light'], '.svg')) { - expect($sponsor['logo']['height'])->toBeLessThanOrEqual(4 * (int) $shown[1], "{$sponsor['name']}'s logo is far larger than it is shown"); + foreach (array_filter([$sponsor['logo']['light'] ?? null, $sponsor['logo']['dark'] ?? null]) as $src) { + $referenced[] = basename($src); + } + } + + $files = array_map('basename', File::files(public_path('images/sponsors'))); + + sort($referenced); + sort($files); + + expect($files)->toBe($referenced, 'A file in public/images/sponsors belongs to no sponsor in sponsors.json'); +}); + +it('describes every tier in every language', function (string $locale): void { + $copy = json_decode(File::get(resource_path("data/content/{$locale}/sponsors.json")), true, 512, JSON_THROW_ON_ERROR); + + expect(array_keys($copy['tiers']['benefit']))->toEqualCanonicalizing(array_column(sponsorsJson()['tiers'], 'placement')); +})->with(array_keys(json_decode((string) file_get_contents(__DIR__ . '/../../../resources/data/locales.json'), true)['supported'])); + +it('reads sponsors from GitHub only on a maintainer machine, never with a token in the app', function (): void { + $paths = [app_path(), config_path(), base_path('routes'), resource_path('js'), base_path('.claude/skills/sponsors')]; + + foreach ($paths as $path) { + foreach (File::allFiles($path) as $file) { + Assert::assertDoesNotMatchRegularExpression('/GITHUB_TOKEN|GH_TOKEN|ghp_|github_pat_|gh auth token/', $file->getContents(), "{$file->getPathname()} reads a GitHub token"); } } }); diff --git a/tests/Feature/Landing/AboutPageTest.php b/tests/Feature/Landing/AboutPageTest.php index ef4c06da..bf20c4d5 100644 --- a/tests/Feature/Landing/AboutPageTest.php +++ b/tests/Feature/Landing/AboutPageTest.php @@ -152,6 +152,9 @@ function aboutVisibleText(string $html): string preg_match('##s', $html, $footer); preg_match('/[\'"]?englishOnly[\'"]?:\s*([\'"])(.*?)\1/u', File::get(resource_path("js/i18n/messages/{$locale}/common.ts")), $marker); + preg_match('#]*id="funding".*?#s', $html, $funding); + + expect($funding[0] ?? '')->toContain("href=\"{$prefix}/sponsors\"")->not->toContain('github.com/sponsors'); expect($policies[0] ?? '')->toContain("href=\"{$prefix}/security\""); expect($brand[0] ?? '')->toContain('href="/brand" hrefLang="en"'); expect(str_contains(aboutVisibleText($brand[0] ?? ''), $marker[2]))->toBe($locale !== Locales::default()); diff --git a/tests/Feature/Landing/HomepageRenderTest.php b/tests/Feature/Landing/HomepageRenderTest.php index 8a51affd..1af849b4 100644 --- a/tests/Feature/Landing/HomepageRenderTest.php +++ b/tests/Feature/Landing/HomepageRenderTest.php @@ -503,9 +503,18 @@ function homeSectionIds(string $html): array it('thanks only the verified sponsors, each as a sponsored link', function (): void { $html = ssrHome('/'); + $data = homeData('sponsors.json'); - foreach (homeData('sponsors.json')['sponsors'] as $sponsor) { - expect($html)->toMatch('#]*href="' . preg_quote(htmlspecialchars($sponsor['url'], ENT_QUOTES), '#') . '"[^>]*rel="sponsored noopener"#'); + preg_match('#]*id="sponsors".*?#s', $html, $section); + + foreach ($data['sponsors'] as $sponsor) { + $link = '#]*href="' . preg_quote(htmlspecialchars($sponsor['url'], ENT_QUOTES), '#') . '"[^>]*rel="sponsored noopener"#'; + + if (in_array($sponsor['placement'], ['lead', 'homepage'], true)) { + expect($section[0] ?? '')->toMatch($link); + } else { + expect($html)->not->toContain(htmlspecialchars($sponsor['url'], ENT_QUOTES)); + } } foreach (['getapps', 'Visnalize', 'Unikorn', 'Xermius'] as $former) { @@ -513,6 +522,23 @@ function homeSectionIds(string $html): array } })->group('ssr'); + it('counts the open homepage places from the data and invites a sponsor to the sponsors page', function (string $path, string $prefix): void { + $data = homeData('sponsors.json'); + $placed = count(array_filter($data['sponsors'], fn(array $sponsor): bool => in_array($sponsor['placement'], ['lead', 'homepage'], true))); + $open = max(0, $data['homepageSlots'] - $placed); + $locale = $prefix === '' ? 'en' : ltrim($prefix, '/'); + $invite = json_decode((string) file_get_contents(resource_path("data/content/{$locale}/home.json")), true)['sponsors']['invite']; + + preg_match('#]*id="sponsors".*?#s', ssrHome($path), $section); + + expect($open)->toBeGreaterThan(0, 'The invite cell is hidden when every place is taken; this test needs an open one'); + expect(substr_count($section[0] ?? '', 'toBe($placed + 1); + expect(substr_count($section[0] ?? '', "href=\"{$prefix}/sponsors\""))->toBe(2, 'The heading link and the invite cell'); + expect($section[0] ?? '')->toContain(htmlspecialchars($invite['title'], ENT_QUOTES)) + ->toContain(str_replace(['{count}', '{total}'], [(string) $open, (string) $data['homepageSlots']], $invite['open'][$open === 1 ? 'one' : 'other'])) + ->not->toContain('github.com/sponsors'); + })->with([['/', ''], ['/vi', '/vi']])->group('ssr'); + it('renders a title built from platforms.json, Product Hunt only as a text link, and wraps the sponsor logos', function (string $path, string $title, string $productHunt): void { $html = ssrHome($path); diff --git a/tests/Feature/Landing/LandingTest.php b/tests/Feature/Landing/LandingTest.php index 66738cb8..1267bb9d 100644 --- a/tests/Feature/Landing/LandingTest.php +++ b/tests/Feature/Landing/LandingTest.php @@ -29,6 +29,7 @@ function landingFamilies(): array 'landing.faq' => ['Faq', ['content', 'facts', 'platforms']], 'landing.about' => ['About', ['content', 'publisher.name', 'repositoryCreated', 'links']], 'landing.security' => ['Security', ['content.sections.0.items', 'facts.macSafeModeLevels', 'links.email']], + 'landing.sponsors' => ['Sponsors', ['content.header.title', 'content.tiers.benefit.name', 'organizationProfiles']], 'landing.privacy' => ['Privacy', ['document.title', 'document.html', 'document.toc']], 'landing.terms' => ['Terms', ['document.title', 'document.html', 'document.toc']], 'landing.refundPolicy' => ['RefundPolicy', ['document.title', 'document.html', 'document.toc']], diff --git a/tests/Feature/Landing/SponsorsPageTest.php b/tests/Feature/Landing/SponsorsPageTest.php new file mode 100644 index 00000000..a7223ea1 --- /dev/null +++ b/tests/Feature/Landing/SponsorsPageTest.php @@ -0,0 +1,132 @@ + + */ +function sponsorsPageLocales(): array +{ + return array_keys(json_decode((string) file_get_contents(__DIR__ . '/../../../resources/data/locales.json'), true)['supported']); +} + +/** + * @return array + */ +function sponsorsPageData(): array +{ + return json_decode(File::get(resource_path('data/sponsors.json')), true, 512, JSON_THROW_ON_ERROR); +} + +/** + * @return array + */ +function sponsorsPageContent(string $locale): array +{ + return json_decode(File::get(resource_path("data/content/{$locale}/sponsors.json")), true, 512, JSON_THROW_ON_ERROR); +} + +function sponsorsPageSection(string $html, string $id): string +{ + preg_match('#]*id="' . $id . '".*?#s', $html, $section); + + return $section[0] ?? ''; +} + +it('renders in every locale', function (string $locale): void { + $path = $locale === Locales::default() ? '/sponsors' : "/{$locale}/sponsors"; + + get($path) + ->assertOk() + ->assertInertia(fn(AssertableInertia $page) => $page + ->component('Sponsors') + ->where('locale', $locale) + ->where('content.header.title', sponsorsPageContent($locale)['header']['title']) + ->where('seo.robots', 'index, follow')); +})->with(sponsorsPageLocales()); + +it('is indexed in every language, with every translation as an alternate', function (): void { + $entry = app(PageRegistry::class)->find('landing.sponsors', []); + + expect($entry->renderLocales)->toBe(Locales::codes()); + expect($entry->hreflangCluster())->toBe(Locales::codes()); +}); + +it('takes every count and price from the data, never from the copy', function (string $locale): void { + $text = implode("\n", array_filter(Arr::dot(sponsorsPageContent($locale)), 'is_string')); + + preg_match_all('/\{([A-Za-z]+)\}/', $text, $slots); + + expect(array_values(array_diff(array_unique($slots[1]), ['count', 'total', 'price'])))->toBe([]); + expect($text)->not->toMatch('/\$\s?\d|\d\s?(US)?\$|\d\s?€|<[a-z]+>/u'); + expect($text)->not->toMatch('/(?with(sponsorsPageLocales()); + +it('server-renders the sponsors by placement, the open places, every tier and a WebPage', function (string $locale): void { + $data = sponsorsPageData(); + $content = sponsorsPageContent($locale); + $prefix = $locale === Locales::default() ? '' : "/{$locale}"; + $html = ssrHtml("{$prefix}/sponsors"); + + preg_match_all('#]*\bid="([a-z-]+)"#', (string) strstr($html, 'toBe(['companies', 'individuals', 'tiers']); + + $companies = sponsorsPageSection($html, 'companies'); + $individuals = sponsorsPageSection($html, 'individuals'); + $tiers = sponsorsPageSection($html, 'tiers'); + + foreach ($data['sponsors'] as $sponsor) { + $url = htmlspecialchars($sponsor['url'], ENT_QUOTES); + $link = '#]*href="' . preg_quote($url, '#') . '"[^>]*rel="sponsored noopener"[^>]*>(.*?)#s'; + + if ($sponsor['placement'] === 'name') { + expect($individuals)->toMatch($link); + expect($companies)->not->toContain($url); + } else { + expect(preg_match($link, $companies, $match))->toBe(1, "{$sponsor['name']} has no sponsored link in #companies"); + expect($match[1])->toContain('toContain($sponsor['name']); + } + } + + $placed = count(array_filter($data['sponsors'], fn(array $sponsor): bool => in_array($sponsor['placement'], ['lead', 'homepage'], true))); + $open = max(0, $data['homepageSlots'] - $placed); + $line = $open > 0 ? $content['companies']['open'][$open === 1 ? 'one' : 'other'] : $content['companies']['full']; + + expect($companies)->toContain(htmlspecialchars(str_replace(['{count}', '{total}'], [(string) $open, (string) $data['homepageSlots']], $line), ENT_QUOTES)); + expect(substr_count($tiers, 'toContain('$' . number_format($tier['monthly'])) + ->toContain('$' . number_format($tier['monthly'] * $data['oneTime']['multiplier'])); + } + } + + expect($tiers)->toContain('href="https://github.com/sponsors/datlechin"'); + + $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); + $nodes = []; + + foreach ($document->querySelectorAll('script[type="application/ld+json"]') as $script) { + array_push($nodes, ...(json_decode((string) $script->textContent, true)['@graph'] ?? [])); + } + + $page = collect($nodes)->firstWhere('@type', 'WebPage'); + + expect($page)->not->toBeNull() + ->and($page['inLanguage'])->toBe(Locales::definition($locale)['hreflang']) + ->and($page['url'])->toEndWith("{$prefix}/sponsors"); +})->with(['en', 'vi'])->group('ssr'); diff --git a/tests/Feature/Localization/LocaleRoutingTest.php b/tests/Feature/Localization/LocaleRoutingTest.php index 3900f394..b4c5f89f 100644 --- a/tests/Feature/Localization/LocaleRoutingTest.php +++ b/tests/Feature/Localization/LocaleRoutingTest.php @@ -182,7 +182,7 @@ function retiredStatus(string $path): ?int it('never lets a slug list swallow a root path or a locale prefix', function (): void { $reserved = [ - 'ios', 'download', 'pricing', 'faq', 'about', 'security', 'privacy', 'terms', 'refund-policy', 'brand', 'blog', 'features', + 'ios', 'download', 'pricing', 'faq', 'about', 'security', 'sponsors', 'privacy', 'terms', 'refund-policy', 'brand', 'blog', 'features', 'databases', 'compare', 'integrations', 'robots.txt', 'sitemap.xml', 'up', 'account', 'checkout', 'og', 'images', 'build', ...array_filter(array_column(Locales::all(), 'prefix')), ]; diff --git a/tests/js/sponsors.test.ts b/tests/js/sponsors.test.ts new file mode 100644 index 00000000..65d4bbf8 --- /dev/null +++ b/tests/js/sponsors.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Sponsor, SponsorsData } from '../../resources/js/lib/data/sponsors.ts'; +import { byName, onHomepage, openSlots } from '../../resources/js/lib/sponsors.ts'; + +const LOGO = { light: '/images/sponsors/x.svg', dark: null, width: 100, height: 20 }; + +function sponsor(id: string, placement: Sponsor['placement']): Sponsor { + const base = { id, name: id, githubLogin: id, url: `https://${id}.example` }; + + return placement === 'name' ? { ...base, placement, logo: null } : { ...base, placement, logo: LOGO }; +} + +function fixture(sponsors: Sponsor[], homepageSlots = 3): SponsorsData { + return { + verifiedAt: '2026-10-29', + verification: { method: 'test', program: 'https://example.com' }, + homepageSlots, + leadSlots: 1, + oneTime: { multiplier: 10, months: 12 }, + tiers: [ + { placement: 'name', monthly: 10 }, + { placement: 'homepage', monthly: 100 }, + { placement: 'lead', monthly: 250 }, + ], + sponsors, + }; +} + +const ids = (list: Sponsor[]): string[] => list.map((item) => item.id); + +test('puts the lead first and keeps data order after it', () => { + const data = fixture([sponsor('a', 'homepage'), sponsor('b', 'name'), sponsor('c', 'lead'), sponsor('d', 'homepage')]); + + assert.deepEqual(ids(onHomepage(data)), ['c', 'a', 'd']); + assert.deepEqual(ids(byName(data)), ['b']); +}); + +test('counts the lead against the homepage slots and never goes below zero', () => { + assert.equal(openSlots(fixture([sponsor('a', 'lead'), sponsor('b', 'name')])), 2); + assert.equal(openSlots(fixture([sponsor('a', 'homepage'), sponsor('b', 'homepage'), sponsor('c', 'homepage')])), 0); + assert.equal(openSlots(fixture([sponsor('a', 'homepage'), sponsor('b', 'homepage'), sponsor('c', 'homepage'), sponsor('d', 'homepage')])), 0); + assert.equal(openSlots(fixture([])), 3); +}); From af5aabd16000fb4b7767dc83f233ffe11a601fcd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:05:24 +0700 Subject: [PATCH 2/3] fix(deps): update symfony/mime for CVE-2026-45070 --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 93300497..102da917 100644 --- a/composer.lock +++ b/composer.lock @@ -4856,16 +4856,16 @@ }, { "name": "symfony/mime", - "version": "v8.1.7", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" + "reference": "8d1232052c9cdef170ed9608b3147bca4f61ff69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", + "url": "https://api.github.com/repos/symfony/mime/zipball/8d1232052c9cdef170ed9608b3147bca4f61ff69", + "reference": "8d1232052c9cdef170ed9608b3147bca4f61ff69", "shasum": "" }, "require": { @@ -4918,7 +4918,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.7" + "source": "https://github.com/symfony/mime/tree/v8.1.10" }, "funding": [ { @@ -4938,7 +4938,7 @@ "type": "tidelift" } ], - "time": "2026-09-04T11:02:17+00:00" + "time": "2026-10-28T17:58:38+00:00" }, { "name": "symfony/polyfill-ctype", From 9dc47a133235533d8b407dbb8a4fa480c25a6844 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:05:43 +0700 Subject: [PATCH 3/3] docs(sponsors): regenerate the README logos from the skill --- .claude/skills/sponsors/SKILL.md | 12 ++++- .../skills/sponsors/scripts/readme_tiles.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/sponsors/scripts/readme_tiles.py diff --git a/.claude/skills/sponsors/SKILL.md b/.claude/skills/sponsors/SKILL.md index 57c20f9c..142a32ab 100644 --- a/.claude/skills/sponsors/SKILL.md +++ b/.claude/skills/sponsors/SKILL.md @@ -55,7 +55,17 @@ tracks them. 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. Ship +## 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' diff --git a/.claude/skills/sponsors/scripts/readme_tiles.py b/.claude/skills/sponsors/scripts/readme_tiles.py new file mode 100644 index 00000000..9715e081 --- /dev/null +++ b/.claude/skills/sponsors/scripts/readme_tiles.py @@ -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'\n' + f'\n{body}\n\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"]*>", 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("")] + assert not re.search(r"{inner}' + else: + mime = {".png": "image/png", ".webp": "image/webp"}[source.suffix] + encoded = base64.b64encode(source.read_bytes()).decode() + body = f'' + (out / f"{sponsor['id']}.svg").write_text(tile(width, HEIGHT, body)) + print(sponsor["id"], width + 2 * PAD_X, HEIGHT + 2 * PAD_Y)