diff --git a/.claude/skills/sponsors/SKILL.md b/.claude/skills/sponsors/SKILL.md new file mode 100644 index 00000000..142a32ab --- /dev/null +++ b/.claude/skills/sponsors/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sponsors +description: > + Keeps resources/data/sponsors.json in step with GitHub Sponsors: who sponsors TablePro now, where + each one is placed, the open homepage places and the tier prices. Use when the user says "check + sponsors", "sponsor drift", "new sponsor", "add as a sponsor", "a sponsor cancelled", + "who sponsors us", or after changing the tiers on GitHub. Read-only against GitHub. +--- + +# Sponsors + +`resources/data/sponsors.json` is the only list the site reads: the homepage strip, `/sponsors` +and the open-place count all come from it. Nothing in the app calls GitHub for sponsors, because +GitHub's sponsorship API needs credentials and this repository holds none. This skill is the check. + +Commands run from this repository's root. + +## 1. Report + +```bash +python3 .claude/skills/sponsors/scripts/drift.py +``` + +It runs `gh api graphql` with the maintainer's own `gh` login, for the user in +`facts.json` → `links.sponsorsProgram` and the TableProApp organization. If `gh` reports a missing +scope, the user runs `gh auth refresh -h github.com -s read:user` themselves. + +| Code | Meaning | Fix | +|---|---|---| +| MISSING | A public sponsor whose amount earns a placement is not listed | Add it (step 2) | +| GONE | A listed sponsor has no current public sponsorship | Remove the entry and its logo file | +| PLACEMENT | Listed above or below what the amount earns | Change `placement` | +| PRIVATE | A private sponsor is listed | Remove it: private sponsors are never named | +| TIERS | Prices on GitHub and in `tiers` differ | Change whichever side is wrong, then rerun | +| STALE | `verifiedAt` is over 30 days old | Rerun and set it once clean | +| LEGACY | Info: kept from before the current tiers (`TIERS_SINCE` in the script) | Nothing | +| EXPIRES | Info: a one-time placement ends within 30 days | Remove it after that date | + +A one-time sponsorship counts for `oneTime.months` from its date, at `multiplier` times the +monthly price. GitHub drops one-time sponsors from its own list after a month, so only the script +tracks them. + +## 2. Change the data + +- One object per sponsor: `id` (kebab-case), `name` as the sponsor writes it, `githubLogin`, + `placement` (`lead`, `homepage` or `name`), `url` with `?ref=tablepro` where the site + takes one, and `logo` (`null` for `name`). +- Display order is the array order; a `lead` is drawn first wherever it sits. +- Logos: the file the sponsor sent, at `public/images/sponsors/{id}.svg|png|webp`, with its real + `width` and `height` (an SVG needs both attributes on its root). A dark-mode file is + `{id}-dark.*` with the same shape; without one the logo sits on a white tile. Never recolour or + filter a logo. Check a third-party SVG holds no script, link or embedded image. +- Set `verifiedAt` to the day of a clean report. + +Never write an amount, a tier per sponsor, a private sponsor or the report itself into the +repository: it is public. The report stays in the session. + +## 3. The README + +TablePro's README (TableProApp/TablePro) shows the same sponsors from `.github/assets/sponsors/`. +Regenerate those files from `public/images/sponsors` and update the README's Sponsors section in a +PR there, after this one deploys: + +```bash +python3 .claude/skills/sponsors/scripts/readme_tiles.py ../TablePro/.github/assets/sponsors +``` + +## 4. Ship + +```bash +php artisan test --compact --filter='Sponsors|HomepageRender' +npm run test:js +``` + +Commit as `chore(sponsors): ...` on a branch and open a PR. `main` deploys itself. 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/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) diff --git a/CLAUDE.md b/CLAUDE.md index 5d6962f3..a8e007d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,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/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", diff --git a/docs/rebuild/assets/features.md b/docs/rebuild/assets/features.md index 245afe88..0801119a 100644 --- a/docs/rebuild/assets/features.md +++ b/docs/rebuild/assets/features.md @@ -1206,7 +1206,7 @@ Shows the local MCP server running and the copyable setup for a client. ### Scene - Platform and release: Mac, TablePro 0.77.0. -- Screen: Settings > Integrations with Enable MCP Server on and the Status row reading "Running on port 23508", and +- Screen: Settings > MCP with Enable MCP Server on and the Status row reading "Running on port 23508", and Connect a Client… open on the Claude Code tab with its numbered steps and snippet. - No token, path with a real user name or e-mail may be visible; use a capture account with a neutral user name. @@ -1218,7 +1218,7 @@ wide. Both variants from the same frame (switch the macOS appearance). ### Locale -One English capture serves both sites (the Vietnamese copy names the pane Cài đặt > Tích hợp). +One English capture serves both sites (the Vietnamese copy names the pane Cài đặt > MCP). ### Open evidence The legacy `/images/blog/mcp-settings-panel.png` is reference only. @@ -1227,7 +1227,7 @@ The legacy `/images/blog/mcp-settings-panel.png` is reference only. None. ### Tóm tắt cho chủ sở hữu -Chụp cận cảnh Cài đặt > Tích hợp (Settings > Integrations) với MCP server đang chạy và hộp Connect a Client… mở ở tab +Chụp cận cảnh Cài đặt > MCP (Settings > MCP) với MCP server đang chạy và hộp Connect a Client… mở ở tab Claude Code, có đoạn cấu hình để sao chép. Không để lộ token hay tên người dùng thật trong đường dẫn. ## mac-connection-library diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index ceb64586..8480cb04 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/positioning.md b/docs/rebuild/design/positioning.md index 1542b91f..dc91ac7a 100644 --- a/docs/rebuild/design/positioning.md +++ b/docs/rebuild/design/positioning.md @@ -612,7 +612,7 @@ move to their hubs, so no page becomes undiscoverable. There is no footer blurb. | Face ID, Touch ID, Optic ID, Handoff, Siri, Dynamic Island | unchanged | | **Click paths in Vietnamese copy.** Show the app's Vietnamese label first, then the English label in parentheses -once. Example: **Cài đặt > Tích hợp** (Settings > Integrations). This is the only place app labels such as "Chế độ an +once. Example: **Cài đặt > MCP** (Settings > MCP). This is the only place app labels such as "Chế độ an toàn" appear. Prose keeps "Safe Mode". ### 11.3 Commerce and account diff --git a/docs/rebuild/design/sitemap.md b/docs/rebuild/design/sitemap.md index 2dbb09d1..f89d594b 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, the account's device limit, the plan file's offline days and the old builds' license timings, refund window, 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. @@ -231,7 +231,7 @@ These are the proposed asset IDs. `docs/visual-assets.md` owns the full brief fo | mac-ai-chat-mobile | crop | 4:5 | /#ai, /features/ai-mcp (<768px) | Close-up of the chat: the answer, its query and the Insert button. | Cận cảnh khung chat: câu trả lời, query đi kèm và nút Insert. | | mac-agent-mode | screenshot | 16:9 | /features/ai-mcp (≥768px) | Agent mode session "Find orders with no items": three statements, one write waiting for Run. | Phiên Agent mode "Find orders with no items": ba câu lệnh, một lệnh ghi đang chờ bấm Run. | | mac-agent-mode-mobile | crop | 4:5 | /features/ai-mcp (<768px) | Close-up of the waiting write: its statement card with Run and Reject. | Cận cảnh lệnh ghi đang chờ: thẻ câu lệnh với nút Run và Reject. | -| mac-mcp-settings | crop | 4:3 | /features/ai-mcp | Settings > Integrations: the MCP server running and the Claude Code setup snippet. | Cài đặt > Tích hợp (Settings > Integrations): MCP server đang chạy, kèm đoạn cấu hình cho Claude Code. | +| mac-mcp-settings | crop | 4:3 | /features/ai-mcp | Settings > MCP: the MCP server running and the Claude Code setup snippet. | Cài đặt > MCP (Settings > MCP): MCP server đang chạy, kèm đoạn cấu hình cho Claude Code. | | mac-connection-ssh-form | crop | 4:3 | /#features, /features/connections (≥768px) | Connection form, Network tab: SSH through bastion.acme.internal with one jump host. | Form connection, tab Network: SSH qua bastion.acme.internal với một jump host. | | mac-connection-ssh-form-mobile | crop | 4:5 | /#features, /features/connections (<768px) | Close-up of the SSH settings: the bastion host, the user, SSH Agent and the jump host row. | Cận cảnh phần cài đặt SSH: host bastion, user, SSH Agent và dòng jump host. | | mac-connection-library | crop | 4:3 | /features/connections | Welcome window: Production and Staging groups, coloured tags and the Chinook sample. | Cửa sổ Welcome: nhóm Production và Staging, tag màu và cơ sở dữ liệu mẫu Chinook. | @@ -537,7 +537,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) | @@ -714,7 +714,7 @@ Matching runs on NFC text and on visible strings only. Values that are URLs or p | Bỏ qua đến nội dung | Chuyển đến nội dung chính | §10.1 | | Bài viết tiếng Anh, or a label whose whole value is "Tiếng Anh" | (tiếng Anh) | §11.4 | | team as a lowercase word | nhóm, thành viên nhóm | §11.3. The tier "Team" and feature names such as Team Library keep their capital | -| An English click path that is not in parentheses: `(?→]` | Cài đặt > Tích hợp (Settings > Integrations); Cài đặt > Giấy phép (Settings > License) | §11.2 click-path rule; the app's own labels come from its Vietnamese string catalog | +| An English click path that is not in parentheses: `(?→]` | Cài đặt > MCP (Settings > MCP); Cài đặt > Giấy phép (Settings > License) | §11.2 click-path rule; the app's own labels come from its Vietnamese string catalog | | giấy phép, except before "AGPL" (the capitalised app label "Giấy phép" in a click path is allowed) | license | §11.1, §11.3 | | Billing and invoices (English strings) | Billing & invoices (VI: Thanh toán và hóa đơn) | §4; spec §0 | diff --git a/public/images/sponsors/getapps-cafe.png b/public/images/sponsors/getapps-cafe.png deleted file mode 100644 index 8ed0b29a..00000000 Binary files a/public/images/sponsors/getapps-cafe.png and /dev/null differ 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 7a91594d..00000000 Binary files a/public/images/sponsors/xermius.webp and /dev/null differ diff --git a/resources/blog/claude-code-cursor-database-mcp.md b/resources/blog/claude-code-cursor-database-mcp.md index 0b4650e7..f90199e4 100644 --- a/resources/blog/claude-code-cursor-database-mcp.md +++ b/resources/blog/claude-code-cursor-database-mcp.md @@ -40,7 +40,7 @@ Restart Cursor after editing the file. If TablePro is installed somewhere other ## Enable the server in TablePro {#turn-on} -1. Open **Settings > Integrations** and turn on **Enable MCP Server**. **Status** reads **Running on port 23508**, or the port it took instead when that one is busy. +1. Open **Settings > MCP** and turn on **Enable MCP Server**. **Status** reads **Running on port 23508**, or the port it took instead when that one is busy. 2. Click **Connect a Client…** and pick **Claude Code** or **Cursor**. The sheet shows the steps and a snippet with the path of your own install. TablePro does not edit other apps' config files, so paste it yourself. 3. Ask the client to list TablePro's tools, or to call `list_connections`. The list includes `list_connections`, `list_tables`, `describe_table` and `execute_query`. @@ -56,7 +56,7 @@ Every saved connection has its own setting. Edit the connection, open the **Opti | **Read Only** | Read the schema and run reads. Any write is refused before it reaches the database. This is the default. | | **Read & Write** | Reads, and writes that pass Safe Mode. | -Leave production on **Read Only** or **Blocked**. Give **Read & Write** to a local or staging database where you want the agent to change data. +Leave production on **Read Only** or **Blocked**, and give the agent a [read-only database role](#database-role). Give **Read & Write** to a local or staging database where you want the agent to change data. ## Require approval for writes {#safe-mode} @@ -83,18 +83,68 @@ With **Read & Write** and **Silent**, ordinary writes can run without a prompt. Some statements are refused whatever the settings: anything that reads or writes files on the server or runs server-side code, and more than one statement in a single call, except a SQL Server script. -For a narrower token than the one `tablepro-mcp` carries, such as **Read Only** limited to a few connections, generate one under **Settings > Integrations > Authentication** and connect the client over HTTP instead. The [MCP Clients](https://docs.tablepro.app/integrations/mcp-clients#http-transport) docs show that config. +For a narrower token than the one `tablepro-mcp` carries, such as **Read Only** limited to a few connections, generate one under **Settings > MCP > Authentication** and connect the client over HTTP instead. The [MCP Clients](https://docs.tablepro.app/integrations/mcp-clients#http-transport) docs show that config. + +## Use a read-only database role {#database-role} + +Every check above runs in TablePro and judges a statement by its text. TablePro knows which built-in functions change data, such as `nextval` or `set_config`, but not what your own functions do: `SELECT archive_orders()` reads like a read. The database checks the role's privileges on every statement, whatever sent it. + +For production, connect the agent as a role that can only read. On PostgreSQL, run the grants as the role that owns the tables, because `ALTER DEFAULT PRIVILEGES` covers only tables that role creates later: + +```sql +CREATE ROLE agent LOGIN PASSWORD 'change-me'; +GRANT CONNECT ON DATABASE app TO agent; +GRANT USAGE ON SCHEMA public TO agent; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO agent; +``` + +On MySQL: + +```sql +CREATE USER 'agent'@'%' IDENTIFIED BY 'change-me'; +GRANT SELECT ON app.* TO 'agent'@'%'; +``` + +Replace `app` with your database, and repeat the schema grants for each schema the agent should read. Then give the role its own connection: + +1. On the welcome window, right-click the production connection and choose **Duplicate**. +2. Edit the copy to sign in as `agent`, and set its **External Clients** to **Read Only**. +3. Set **External Clients** on the original to **Blocked**, so clients see only the copy. + +To check the role, open the copy in TablePro and run a write that matches no rows: + +```sql +UPDATE orders SET status = status WHERE false; +``` + +PostgreSQL answers `permission denied for table orders`, MySQL `UPDATE command denied`. If it runs, the role can write. + +What a read-only role does not cover: + +- On PostgreSQL, a `SECURITY DEFINER` function runs with its owner's privileges, and any role may call a function unless `EXECUTE` is revoked from `PUBLIC`. List them, and revoke `EXECUTE` from `PUBLIC` on any that write: + + ```sql + SELECT p.oid::regprocedure + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema'); + ``` + +- On MySQL, grant nothing beyond `SELECT`. A routine runs as its definer by default, so `EXECUTE` would let the agent call one that writes. +- `ALTER ROLE agent SET default_transaction_read_only = on` is not a boundary: the session can `SET` it back. ## Review activity {#activity} -**View Activity…** in **Settings > Integrations** lists every tool call and query, with the connection and the outcome. Statements there are stored as a SHA-256 digest, not as text. Queries a client runs also appear in the query history of that connection, unless you turn off **Log MCP queries in history**. +**View Activity…** in **Settings > MCP** lists every tool call and query, with the connection and the outcome. Statements there are stored as a SHA-256 digest, not as text. Queries a client runs also appear in the query history of that connection, unless you turn off **Log MCP queries in history**. ## Limitations {#limits} - The server listens on `127.0.0.1` only. There is no remote mode; a client on another machine needs an SSH port forward you set up and own. - It runs in the Mac app. The iPhone and iPad app has no MCP server. - Clients see your saved connections, never their passwords, and cannot create or edit connections or change Safe Mode. -- A result stops at **Default row limit**, 500 rows unless you change it, and a client can ask for more only up to **Maximum row limit**, 10,000 by default. Both are in **Settings > Integrations**. +- A result stops at **Default row limit**, 500 rows unless you change it, and a client can ask for more only up to **Maximum row limit**, 10,000 by default. +- A call waits at most **Query timeout**, 30 seconds unless you change it, and a client can ask for up to 300. A read still running then is cancelled. All three limits are in **Settings > MCP**. ## Troubleshooting {#troubleshooting} diff --git a/resources/blog/vi/claude-code-cursor-database-mcp.md b/resources/blog/vi/claude-code-cursor-database-mcp.md index 1f843fd4..1c7f11cf 100644 --- a/resources/blog/vi/claude-code-cursor-database-mcp.md +++ b/resources/blog/vi/claude-code-cursor-database-mcp.md @@ -40,7 +40,7 @@ Khởi động lại Cursor sau khi sửa file. Nếu TablePro được cài ở ## Bật server trong TablePro {#turn-on} -1. Mở **Cài đặt > Tích hợp** (Settings > Integrations) và bật **Bật MCP Server** (Enable MCP Server). **Trạng thái** (Status) hiện **Đang chạy trên cổng 23508** (Running on port 23508), hoặc port mà server lấy thay khi port đó đang bận. +1. Mở **Cài đặt > MCP** (Settings > MCP) và bật **Bật MCP Server** (Enable MCP Server). **Trạng thái** (Status) hiện **Đang chạy trên cổng 23508** (Running on port 23508), hoặc port mà server lấy thay khi port đó đang bận. 2. Bấm **Kết nối client…** (Connect a Client…) và chọn **Claude Code** hoặc **Cursor**. Bảng này có các bước thiết lập và đoạn cấu hình chứa đúng đường dẫn bản cài của bạn. TablePro không sửa file cấu hình của ứng dụng khác, nên bạn tự dán vào. 3. Nhờ client liệt kê các tool của TablePro, hoặc gọi `list_connections`. Danh sách có `list_connections`, `list_tables`, `describe_table` và `execute_query`. @@ -56,7 +56,7 @@ Mỗi connection đã lưu có thiết lập riêng. Sửa connection, mở tab | **Read Only** | Đọc schema và chạy câu lệnh đọc. Mọi câu lệnh ghi bị từ chối trước khi tới cơ sở dữ liệu. Đây là mức mặc định. | | **Read & Write** | Đọc, và ghi những gì Safe Mode cho qua. | -Hãy để production ở **Read Only** hoặc **Blocked**. Chỉ cho **Read & Write** với cơ sở dữ liệu local hoặc staging, nơi bạn muốn agent thay đổi dữ liệu. +Hãy để production ở **Read Only** hoặc **Blocked**, và cho agent dùng một [role chỉ đọc trong cơ sở dữ liệu](#database-role). Chỉ cho **Read & Write** với cơ sở dữ liệu local hoặc staging, nơi bạn muốn agent thay đổi dữ liệu. ## Yêu cầu duyệt trước khi ghi {#safe-mode} @@ -83,18 +83,68 @@ Với **Read & Write** và **Silent**, thao tác ghi thông thường chạy đ Một số câu lệnh luôn bị từ chối bất kể thiết lập: mọi thứ đọc hoặc ghi file trên server hay chạy code phía server, và nhiều hơn một câu lệnh trong một lần gọi, trừ script SQL Server. -Nếu cần token hẹp hơn token của `tablepro-mcp`, ví dụ **Read Only** và chỉ cho vài connection, hãy tạo một token trong **Cài đặt > Tích hợp > Xác thực** (Settings > Integrations > Authentication) rồi cho client kết nối qua HTTP. Tài liệu [MCP Clients](https://docs.tablepro.app/integrations/mcp-clients#http-transport) có cấu hình cho cách này. +Nếu cần token hẹp hơn token của `tablepro-mcp`, ví dụ **Read Only** và chỉ cho vài connection, hãy tạo một token trong **Cài đặt > MCP > Xác thực** (Settings > MCP > Authentication) rồi cho client kết nối qua HTTP. Tài liệu [MCP Clients](https://docs.tablepro.app/integrations/mcp-clients#http-transport) có cấu hình cho cách này. + +## Dùng role chỉ đọc trong cơ sở dữ liệu {#database-role} + +Mọi lớp kiểm tra ở trên chạy trong TablePro và đánh giá câu lệnh qua nội dung của nó. TablePro biết function có sẵn nào thay đổi dữ liệu, như `nextval` hay `set_config`, nhưng không biết function của chính bạn làm gì: `SELECT archive_orders()` trông như một câu lệnh đọc. Còn cơ sở dữ liệu kiểm tra quyền của role ở mọi câu lệnh, bất kể ai gửi. + +Với production, hãy cho agent kết nối bằng một role chỉ đọc được. Trên PostgreSQL, chạy các lệnh grant bằng role sở hữu các table, vì `ALTER DEFAULT PRIVILEGES` chỉ áp dụng cho table mà chính role đó tạo về sau: + +```sql +CREATE ROLE agent LOGIN PASSWORD 'change-me'; +GRANT CONNECT ON DATABASE app TO agent; +GRANT USAGE ON SCHEMA public TO agent; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO agent; +``` + +Trên MySQL: + +```sql +CREATE USER 'agent'@'%' IDENTIFIED BY 'change-me'; +GRANT SELECT ON app.* TO 'agent'@'%'; +``` + +Thay `app` bằng tên cơ sở dữ liệu của bạn, và lặp lại các lệnh grant schema cho mỗi schema mà agent cần đọc. Sau đó tạo cho role này một connection riêng: + +1. Trên cửa sổ chào mừng, bấm chuột phải vào connection production và chọn **Nhân bản** (Duplicate). +2. Sửa bản sao để đăng nhập bằng `agent`, và đặt **Client bên ngoài** (External Clients) của nó thành **Read Only**. +3. Đặt **Client bên ngoài** của connection gốc thành **Blocked**, để client chỉ thấy bản sao. + +Để kiểm tra role, mở bản sao trong TablePro và chạy một câu lệnh ghi không khớp dòng nào: + +```sql +UPDATE orders SET status = status WHERE false; +``` + +PostgreSQL trả về `permission denied for table orders`, MySQL trả về `UPDATE command denied`. Nếu câu lệnh chạy được, role đó vẫn ghi được. + +Những gì role chỉ đọc không che được: + +- Trên PostgreSQL, function `SECURITY DEFINER` chạy với quyền của owner, và role nào cũng gọi được function trừ khi `EXECUTE` đã bị thu hồi khỏi `PUBLIC`. Liệt kê các function đó, rồi thu hồi `EXECUTE` khỏi `PUBLIC` ở những function có ghi dữ liệu: + + ```sql + SELECT p.oid::regprocedure + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema'); + ``` + +- Trên MySQL, đừng cấp gì ngoài `SELECT`. Routine mặc định chạy với quyền của definer, nên `EXECUTE` sẽ cho agent gọi được một routine có ghi dữ liệu. +- `ALTER ROLE agent SET default_transaction_read_only = on` không phải ranh giới: session có thể `SET` lại. ## Xem hoạt động {#activity} -**Xem hoạt động…** (View Activity…) trong **Cài đặt > Tích hợp** liệt kê mọi lần gọi tool và mọi query, kèm connection và kết quả. Câu lệnh ở đó được lưu dưới dạng mã băm SHA-256, không phải văn bản. Query do client chạy cũng xuất hiện trong lịch sử query của connection đó, trừ khi bạn tắt **Ghi truy vấn MCP vào lịch sử** (Log MCP queries in history). +**Xem hoạt động…** (View Activity…) trong **Cài đặt > MCP** liệt kê mọi lần gọi tool và mọi query, kèm connection và kết quả. Câu lệnh ở đó được lưu dưới dạng mã băm SHA-256, không phải văn bản. Query do client chạy cũng xuất hiện trong lịch sử query của connection đó, trừ khi bạn tắt **Ghi truy vấn MCP vào lịch sử** (Log MCP queries in history). ## Giới hạn {#limits} - Server chỉ lắng nghe trên `127.0.0.1`. Không có chế độ truy cập từ xa; client trên máy khác cần một SSH port forward do bạn tự thiết lập và tự chịu trách nhiệm. - Server chạy trong ứng dụng cho Mac. Ứng dụng cho iPhone và iPad không có MCP server. - Client thấy các connection đã lưu nhưng không bao giờ thấy mật khẩu, và không tạo, sửa connection hay đổi Safe Mode được. -- Kết quả dừng ở **Giới hạn dòng mặc định** (Default row limit), 500 dòng nếu bạn không đổi, và client chỉ xin thêm được tới **Giới hạn dòng tối đa** (Maximum row limit), mặc định là 10.000. Cả hai nằm trong **Cài đặt > Tích hợp**. +- Kết quả dừng ở **Giới hạn dòng mặc định** (Default row limit), 500 dòng nếu bạn không đổi, và client chỉ xin thêm được tới **Giới hạn dòng tối đa** (Maximum row limit), mặc định là 10.000. +- Mỗi lần gọi chờ tối đa **Thời gian chờ truy vấn** (Query timeout), 30 giây nếu bạn không đổi, và client xin được tới 300 giây. Câu lệnh đọc còn chạy lúc đó sẽ bị hủy. Cả ba giới hạn nằm trong **Cài đặt > MCP** (Settings > MCP). ## Xử lý lỗi {#troubleshooting} diff --git a/resources/data/assets.json b/resources/data/assets.json index 5adfe2fe..e18646f3 100644 --- a/resources/data/assets.json +++ b/resources/data/assets.json @@ -3379,18 +3379,18 @@ "locale": "shared", "mobile": null, "description": { - "en": "Settings > Integrations: the MCP server running and the Claude Code setup snippet.", - "vi": "Cài đặt > Tích hợp (Settings > Integrations): MCP server đang chạy, kèm đoạn cấu hình cho Claude Code.", - "pt-BR": "Settings > Integrations: o servidor MCP em execução e o trecho de configuração do Claude Code.", - "it": "Settings > Integrations: il server MCP in esecuzione e il frammento di configurazione per Claude Code.", - "id": "Settings > Integrations: server MCP berjalan dan cuplikan pengaturan Claude Code.", - "zh-Hans": "Settings > Integrations:MCP 服务器正在运行,并显示 Claude Code 设置代码片段。", - "zh-Hant": "Settings > Integrations:MCP 伺服器正在執行,並顯示 Claude Code 設定程式碼片段。", - "es": "Ajustes > Integraciones: servidor MCP en ejecución y fragmento de configuración de Claude Code.", - "de": "Einstellungen > Integrationen: laufender MCP-Server und Einrichtungsschnipsel für Claude Code.", - "fr": "Réglages > Intégrations : serveur MCP en cours d’exécution et extrait de configuration Claude Code.", - "ja": "Settings > Integrations。MCP サーバーが動作中で、Claude Code の設定スニペットを表示しています。", - "ko": "Settings > Integrations. MCP 서버가 실행 중이며 Claude Code 설정 코드 조각이 표시됩니다." + "en": "Settings > MCP: the MCP server running and the Claude Code setup snippet.", + "vi": "Cài đặt > MCP (Settings > MCP): MCP server đang chạy, kèm đoạn cấu hình cho Claude Code.", + "pt-BR": "Settings > MCP: o servidor MCP em execução e o trecho de configuração do Claude Code.", + "it": "Settings > MCP: il server MCP in esecuzione e il frammento di configurazione per Claude Code.", + "id": "Settings > MCP: server MCP berjalan dan cuplikan pengaturan Claude Code.", + "zh-Hans": "Settings > MCP:MCP 服务器正在运行,并显示 Claude Code 设置代码片段。", + "zh-Hant": "Settings > MCP:MCP 伺服器正在執行,並顯示 Claude Code 設定程式碼片段。", + "es": "Ajustes > MCP: servidor MCP en ejecución y fragmento de configuración de Claude Code.", + "de": "Einstellungen > MCP: laufender MCP-Server und Einrichtungsschnipsel für Claude Code.", + "fr": "Réglages > MCP : serveur MCP en cours d’exécution et extrait de configuration Claude Code.", + "ja": "Settings > MCP。MCP サーバーが動作中で、Claude Code の設定スニペットを表示しています。", + "ko": "Settings > MCP. MCP 서버가 실행 중이며 Claude Code 설정 코드 조각이 표시됩니다." }, "alt": { "en": "The Connect a Client sheet in the MCP settings, with the claude mcp add command for Claude Code.", 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/features/ai-mcp.json b/resources/data/content/de/features/ai-mcp.json index 94431578..fe3203e5 100644 --- a/resources/data/content/de/features/ai-mcp.json +++ b/resources/data/content/de/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Einen Client verbinden", "paragraphs": [ - "Anleitungen für {mcpSetupClients} findest du unter Settings > Integrations. Die enthaltene tablepro-mcp-Bridge unterstützt auch {mcpBridgeClients} und startet die App bei Bedarf. Raycast nutzt eine separate Kopplung mit Freigabe.", + "Anleitungen für {mcpSetupClients} findest du unter Settings > MCP. Die enthaltene tablepro-mcp-Bridge unterstützt auch {mcpBridgeClients} und startet die App bei Bedarf. Raycast nutzt eine separate Kopplung mit Freigabe.", "Bitte einen Client etwa, doppelte E-Mail-Adressen zu finden. Er kann das Schema prüfen, die Spalte bestimmen und ein erlaubtes SELECT über deine gespeicherte Verbindung ausführen." ] }, 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/features/ai-mcp.json b/resources/data/content/en/features/ai-mcp.json index 42cf3591..71bf6a75 100644 --- a/resources/data/content/en/features/ai-mcp.json +++ b/resources/data/content/en/features/ai-mcp.json @@ -53,7 +53,7 @@ "id": "connect", "title": "Connect a client", "paragraphs": [ - "Find setup instructions for {mcpSetupClients} in Settings > Integrations. The bundled tablepro-mcp bridge also supports {mcpBridgeClients} and starts the app when needed. Raycast uses a separate pairing flow that requires approval.", + "Find setup instructions for {mcpSetupClients} in Settings > MCP. The bundled tablepro-mcp bridge also supports {mcpBridgeClients} and starts the app when needed. Raycast uses a separate pairing flow that requires approval.", "For example, ask a client to find duplicate email addresses. It can inspect the schema, identify the column and run a permitted SELECT through your saved connection." ] }, 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/features/ai-mcp.json b/resources/data/content/es/features/ai-mcp.json index 8f41f3fe..3462d974 100644 --- a/resources/data/content/es/features/ai-mcp.json +++ b/resources/data/content/es/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Conecta un cliente", "paragraphs": [ - "Encuentra instrucciones para {mcpSetupClients} en Settings > Integrations. El puente tablepro-mcp incluido también admite {mcpBridgeClients} e inicia la app cuando hace falta. Raycast usa un proceso de emparejamiento aparte que requiere aprobación.", + "Encuentra instrucciones para {mcpSetupClients} en Settings > MCP. El puente tablepro-mcp incluido también admite {mcpBridgeClients} e inicia la app cuando hace falta. Raycast usa un proceso de emparejamiento aparte que requiere aprobación.", "Por ejemplo, pide a un cliente que encuentre correos duplicados. Puede inspeccionar el esquema, identificar la columna y ejecutar un SELECT permitido mediante tu conexión guardada." ] }, 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/features/ai-mcp.json b/resources/data/content/fr/features/ai-mcp.json index 4aeac9f1..7f645867 100644 --- a/resources/data/content/fr/features/ai-mcp.json +++ b/resources/data/content/fr/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Connectez un client", "paragraphs": [ - "Trouvez les instructions pour {mcpSetupClients} dans Réglages > Intégrations (Settings > Integrations). Le pont tablepro-mcp inclus prend aussi en charge {mcpBridgeClients} et démarre l’app si nécessaire. Raycast utilise un appairage distinct qui nécessite approbation.", + "Trouvez les instructions pour {mcpSetupClients} dans Réglages > MCP (Settings > MCP). Le pont tablepro-mcp inclus prend aussi en charge {mcpBridgeClients} et démarre l’app si nécessaire. Raycast utilise un appairage distinct qui nécessite approbation.", "Demandez par exemple à un client de trouver les adresses e-mail en double. Il peut inspecter le schéma, identifier la colonne et exécuter un SELECT autorisé via votre connexion enregistrée." ] }, 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/features/ai-mcp.json b/resources/data/content/id/features/ai-mcp.json index 2c32de39..1dfc9e71 100644 --- a/resources/data/content/id/features/ai-mcp.json +++ b/resources/data/content/id/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Hubungkan klien", "paragraphs": [ - "Temukan petunjuk pengaturan untuk {mcpSetupClients} di Settings > Integrations. Jembatan tablepro-mcp yang dibundel juga mendukung {mcpBridgeClients} dan memulai aplikasi bila diperlukan. Raycast menggunakan aliran pemasangan terpisah yang memerlukan persetujuan.", + "Temukan petunjuk pengaturan untuk {mcpSetupClients} di Settings > MCP. Jembatan tablepro-mcp yang dibundel juga mendukung {mcpBridgeClients} dan memulai aplikasi bila diperlukan. Raycast menggunakan aliran pemasangan terpisah yang memerlukan persetujuan.", "Misalnya, minta klien untuk menemukan alamat email duplikat. Itu dapat memeriksa skema, mengidentifikasi kolom dan menjalankan SELECT yang diizinkan melalui koneksi tersimpan Anda." ] }, 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/features/ai-mcp.json b/resources/data/content/it/features/ai-mcp.json index 0071b69b..695e3037 100644 --- a/resources/data/content/it/features/ai-mcp.json +++ b/resources/data/content/it/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Connetti un client", "paragraphs": [ - "Trova le istruzioni di configurazione per {mcpSetupClients} in Settings > Integrations. Il bridge tablepro-mcp in bundle supporta anche {mcpBridgeClients} e avvia l'app quando necessario. Raycast utilizza un flusso di abbinamento separato che richiede l'approvazione.", + "Trova le istruzioni di configurazione per {mcpSetupClients} in Settings > MCP. Il bridge tablepro-mcp in bundle supporta anche {mcpBridgeClients} e avvia l'app quando necessario. Raycast utilizza un flusso di abbinamento separato che richiede l'approvazione.", "Ad esempio, chiedi a un cliente di trovare indirizzi email duplicati. Può ispezionare lo schema, identificare la colonna ed eseguire uno SELECT consentito tramite la connessione salvata." ] }, 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/features/ai-mcp.json b/resources/data/content/ja/features/ai-mcp.json index 606ad19f..9ea75634 100644 --- a/resources/data/content/ja/features/ai-mcp.json +++ b/resources/data/content/ja/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "クライアントを接続する", "paragraphs": [ - "{mcpSetupClients} の設定手順は Settings > Integrations にあります。同梱の tablepro-mcp ブリッジは {mcpBridgeClients} にも対応し、必要ならアプリを起動します。Raycast は承認を必要とする別のペアリング手順を使います。", + "{mcpSetupClients} の設定手順は Settings > MCP にあります。同梱の tablepro-mcp ブリッジは {mcpBridgeClients} にも対応し、必要ならアプリを起動します。Raycast は承認を必要とする別のペアリング手順を使います。", "たとえば、重複するメールアドレスを探すよう依頼できます。クライアントはスキーマを調べて列を特定し、保存済み接続から許可された SELECT を実行できます。" ] }, 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/features/ai-mcp.json b/resources/data/content/ko/features/ai-mcp.json index f01a2073..6c07b095 100644 --- a/resources/data/content/ko/features/ai-mcp.json +++ b/resources/data/content/ko/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "클라이언트 연결", "paragraphs": [ - "{mcpSetupClients} 설정 안내는 설정 > 통합 (Settings > Integrations)에서 확인하세요. 내장 tablepro-mcp 브리지는 {mcpBridgeClients}도 지원하며 필요하면 앱을 시작합니다. Raycast는 승인이 필요한 별도의 페어링 절차를 사용합니다.", + "{mcpSetupClients} 설정 안내는 설정 > MCP (Settings > MCP)에서 확인하세요. 내장 tablepro-mcp 브리지는 {mcpBridgeClients}도 지원하며 필요하면 앱을 시작합니다. Raycast는 승인이 필요한 별도의 페어링 절차를 사용합니다.", "예를 들어 중복 이메일 주소를 찾아 달라고 요청하세요. 클라이언트는 스키마를 확인하고 열을 찾은 뒤 저장된 연결로 허용된 SELECT를 실행할 수 있습니다." ] }, 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/features/ai-mcp.json b/resources/data/content/pt-BR/features/ai-mcp.json index a00ded06..021760f3 100644 --- a/resources/data/content/pt-BR/features/ai-mcp.json +++ b/resources/data/content/pt-BR/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Conecte um cliente", "paragraphs": [ - "Encontre instruções de configuração para {mcpSetupClients} em Settings > Integrations. A ponte tablepro-mcp incluída também suporta {mcpBridgeClients} e inicia o aplicativo quando necessário. O Raycast usa um fluxo de emparelhamento separado que requer aprovação.", + "Encontre instruções de configuração para {mcpSetupClients} em Settings > MCP. A ponte tablepro-mcp incluída também suporta {mcpBridgeClients} e inicia o aplicativo quando necessário. O Raycast usa um fluxo de emparelhamento separado que requer aprovação.", "Por exemplo, peça a um cliente para encontrar endereços de e-mail duplicados. Ele pode inspecionar o esquema, identificar a coluna e executar um SELECT permitido por meio de sua conexão salva." ] }, 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/features/ai-mcp.json b/resources/data/content/vi/features/ai-mcp.json index 72380f93..d8b2e02b 100644 --- a/resources/data/content/vi/features/ai-mcp.json +++ b/resources/data/content/vi/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "Kết nối một client", "paragraphs": [ - "Xem hướng dẫn thiết lập {mcpSetupClients} trong Cài đặt > Tích hợp (Settings > Integrations). Bridge tablepro-mcp có sẵn cũng hỗ trợ {mcpBridgeClients} và mở ứng dụng khi cần. Raycast dùng luồng ghép đôi riêng, cần bạn duyệt.", + "Xem hướng dẫn thiết lập {mcpSetupClients} trong Cài đặt > MCP (Settings > MCP). Bridge tablepro-mcp có sẵn cũng hỗ trợ {mcpBridgeClients} và mở ứng dụng khi cần. Raycast dùng luồng ghép đôi riêng, cần bạn duyệt.", "Ví dụ, nhờ client tìm địa chỉ email trùng. Client có thể xem schema, tìm cột và chạy SELECT được cho phép qua connection đã lưu." ] }, 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/features/ai-mcp.json b/resources/data/content/zh-Hans/features/ai-mcp.json index ef992094..6434e3e6 100644 --- a/resources/data/content/zh-Hans/features/ai-mcp.json +++ b/resources/data/content/zh-Hans/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "连接客户端", "paragraphs": [ - "在 设置 > 集成 (Settings > Integrations) 中查看 {mcpSetupClients} 的设置说明。内置 tablepro-mcp 桥接也支持 {mcpBridgeClients},并在需要时启动 App。Raycast 使用单独的配对流程,需要批准。", + "在 设置 > MCP (Settings > MCP) 中查看 {mcpSetupClients} 的设置说明。内置 tablepro-mcp 桥接也支持 {mcpBridgeClients},并在需要时启动 App。Raycast 使用单独的配对流程,需要批准。", "例如,让客户端查找重复邮箱地址。它可检查结构、确定列,并通过已保存连接执行获准的 SELECT。" ] }, 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/features/ai-mcp.json b/resources/data/content/zh-Hant/features/ai-mcp.json index fe83976b..a2399348 100644 --- a/resources/data/content/zh-Hant/features/ai-mcp.json +++ b/resources/data/content/zh-Hant/features/ai-mcp.json @@ -58,7 +58,7 @@ "id": "connect", "title": "連接用戶端", "paragraphs": [ - "在 設定 > 整合 (Settings > Integrations) 中查看 {mcpSetupClients} 的設定說明。內建 tablepro-mcp 橋接也支援 {mcpBridgeClients},並在需要時啟動 App。Raycast 使用單獨的配對流程,需要核准。", + "在 設定 > MCP (Settings > MCP) 中查看 {mcpSetupClients} 的設定說明。內建 tablepro-mcp 橋接也支援 {mcpBridgeClients},並在需要時啟動 App。Raycast 使用單獨的配對流程,需要核准。", "例如,讓用戶端尋找重複信箱地址。它可檢查綱要、確定欄位,並透過已儲存連線執行獲准的 SELECT。" ] }, 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 8770c5a4..83de5a6a 100644 --- a/resources/js/components/faq/content-links.tsx +++ b/resources/js/components/faq/content-links.tsx @@ -32,7 +32,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/features/README.md b/resources/js/components/features/README.md index 50c32650..0c8973e1 100644 --- a/resources/js/components/features/README.md +++ b/resources/js/components/features/README.md @@ -170,7 +170,7 @@ in a block's `engines`: `{ "label": "Plan as text", "list": "explainText" }`. Ro key, never "giấy phép" except the AGPLv3. Spelling: khóa, xóa, hóa, tùy, hủy. NFC. - Same keys, same array lengths, same section and block ids, same `asset` ids and the same `{tokens}` as English (`ContentParityTest`). Feature, tier, mode and engine names stay English. -- Click paths show the Vietnamese app label first: **Cài đặt > Tích hợp** (Settings > Integrations). +- Click paths show the Vietnamese app label first: **Cài đặt > MCP** (Settings > MCP). ## Writing rules (sitemap §E.4, positioning §12) 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 4bb354c2..9102bb6b 100644 --- a/resources/js/components/site/site-footer.tsx +++ b/resources/js/components/site/site-footer.tsx @@ -301,7 +301,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 3fb19150..6ad7c31d 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 87f2b3ee..90dfa8d5 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 17468230..f5365dae 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', "https://account.tablepro.app/?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', "https://account.tablepro.app/?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 21350c33..482c714e 100644 --- a/tests/Feature/Data/LocalesDataTest.php +++ b/tests/Feature/Data/LocalesDataTest.php @@ -47,7 +47,7 @@ function localePrefixes(): array // The paths nginx answers on this origin without this app. 'account', 'checkout', 'thank-you', 'newsletter', 'webhooks', // 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/BlogTest.php b/tests/Feature/Landing/BlogTest.php index 239bac42..092fefdd 100644 --- a/tests/Feature/Landing/BlogTest.php +++ b/tests/Feature/Landing/BlogTest.php @@ -211,6 +211,15 @@ function blogSlotIds(string $html): array expect(glob(resource_path('blog/vi/*.md')))->toHaveCount(count(BLOG_GUIDES)); }); +it('gives each Vietnamese guide the same sections as the original, in the same order', function (): void { + $anchors = fn(string $path): array => preg_match_all('/^#{2,6} .*\{#([\w-]+)\}\s*$/m', File::get($path), $matches) > 0 ? $matches[1] : []; + + foreach (array_keys(BLOG_GUIDES) as $slug) { + expect($anchors(resource_path("blog/vi/{$slug}.md"))) + ->toBe($anchors(resource_path("blog/{$slug}.md")), "{$slug}: a section of the original is missing or out of place in the translation"); + } +}); + it('removes the merged guides, whose URLs now redirect', function (string $slug, string $target): void { expect(File::exists(resource_path("blog/{$slug}.md")))->toBeFalse(); diff --git a/tests/Feature/Landing/HomepageRenderTest.php b/tests/Feature/Landing/HomepageRenderTest.php index da8411aa..0e21ddf7 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 6f93f7ef..a0c3c4df 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/Support/app-ui-labels.php b/tests/Support/app-ui-labels.php index 583dd046..6aad92c3 100644 --- a/tests/Support/app-ui-labels.php +++ b/tests/Support/app-ui-labels.php @@ -97,9 +97,9 @@ 'Settings > General' => ['fr' => 'Réglages > Général', 'ko' => '설정 > 일반', 'zh-Hans' => '设置 > 通用', 'zh-Hant' => '設定 > 一般'], 'Settings > General > Privacy' => ['fr' => 'Réglages > Général > Confidentialité', 'ko' => '설정 > 일반 > 개인정보 보호', 'zh-Hans' => '设置 > 通用 > 隐私', 'zh-Hant' => '設定 > 一般 > 隱私'], 'Settings > General > Software Update' => ['fr' => 'Réglages > Général > Mise à jour de logiciels', 'ko' => '설정 > 일반 > 소프트웨어 업데이트', 'zh-Hans' => '设置 > 通用 > 软件更新', 'zh-Hant' => '設定 > 一般 > 軟體更新'], - 'Settings > Integrations' => ['fr' => 'Réglages > Intégrations', 'ko' => '설정 > 통합', 'zh-Hans' => '设置 > 集成', 'zh-Hant' => '設定 > 整合'], 'Settings > License' => ['fr' => 'Réglages > Licence', 'ko' => '설정 > 라이선스', 'zh-Hans' => '设置 > 许可证', 'zh-Hant' => '設定 > 授權'], 'Settings > Live Activities > Hide Query' => ['ko' => '설정 > 실시간 현황 > 쿼리 가리기', 'zh-Hans' => '设置 > 实时活动 > 隐藏查询', 'zh-Hant' => '設定 > 即時動態 > 隱藏查詢'], + 'Settings > MCP' => ['fr' => 'Réglages > MCP', 'ko' => '설정 > MCP', 'zh-Hans' => '设置 > MCP', 'zh-Hant' => '設定 > MCP'], 'Settings > Plugins' => ['fr' => 'Réglages > Plugins', 'ko' => '설정 > 플러그인', 'zh-Hans' => '设置 > 插件', 'zh-Hant' => '設定 > 外掛'], 'Settings > Privacy' => ['ko' => '설정 > 개인정보 보호', 'zh-Hans' => '设置 > 隐私', 'zh-Hant' => '設定 > 隱私權'], 'Settings > Security' => ['ko' => '설정 > 보안', 'zh-Hans' => '设置 > 安全', 'zh-Hant' => '設定 > 安全性'], 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); +});