Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Star on GitHub buttons with a live star count on the public site #328

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/github-star-buttons
11 files changed+374−16base 24957a3 → head 98a55ab
Files (11)
modifiedREADME.md+2−1View file
@@ -2,6 +2,7 @@
22
33 **The open-source GitHub and GitHub Actions alternative that runs entirely on Cloudflare.** Git hosting, pull requests, code review and drop-in GitHub Actions CI, with agent tasks, agent code review and plugins for Claude Code and Codex, built for teams of people and AI agents. Deploy it to your own Cloudflare account: no servers, no runner machines, nothing to patch.
44
5+[![GitHub stars](https://img.shields.io/github/stars/codician-team/diffinery?style=flat&logo=github&label=stars)](https://github.com/codician-team/diffinery)
56 [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
67 [![GitHub Actions compatible](https://img.shields.io/badge/GitHub%20Actions-compatible-2088ff.svg)](https://diffinery.com/docs/actions/compatibility)
78 [![Runs on Cloudflare Workers](https://img.shields.io/badge/runs%20on-Cloudflare%20Workers-f38020.svg)](https://diffinery.com/built-on-cloudflare)
@@ -21,7 +22,7 @@
2122 - **Source:** [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where development, CI and deploys happen, mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery).
2223 - **Documentation:** [diffinery.com/docs](https://diffinery.com/docs), also as Markdown and in [llms.txt](https://diffinery.com/llms.txt) and [llms-full.txt](https://diffinery.com/llms-full.txt) for AI assistants.
2324
24-If Diffinery is useful to you, a star on GitHub helps other people find it.
25+If Diffinery is useful to you, [a star on GitHub](https://github.com/codician-team/diffinery) helps other people find it.
2526
2627 ## Contents
2728
modifiedapps/diffinery/scripts/make-ui-icons.mjs+2−0View file
@@ -63,6 +63,8 @@
6363 flag: ["Flag", "regular"],
6464 prohibit: ["Prohibit", "regular"],
6565 eyeSlash: ["EyeSlash", "regular"],
66+ github: ["GithubLogo", "fill"],
67+ star: ["Star", "regular"],
6668 };
6769
6870 const out = {};
modifiedapps/diffinery/site/privacy.md+2−1View file
@@ -2,7 +2,7 @@
22 title: Privacy policy
33 seo_title: "Privacy policy · Diffinery"
44 description: What personal data diffinery.com and the Diffinery CLI, editor extensions and agent plugins handle, who processes it, and how long it is kept.
5-updated: 2026-10-25
5+updated: 2026-10-26
66 ---
77
88 This policy covers **diffinery.com**, the website and Diffinery instance that Codician runs, and the Diffinery **CLI, editor extensions and AI-agent plugins** that Codician publishes. It explains what personal data they handle, why, who processes it, how long it is kept, and what you can ask of us.
@@ -27,6 +27,7 @@
2727
2828 - **Cloudflare**, which hosts diffinery.com, processes your IP address, browser user agent and the pages you request to serve the site and protect it from attacks. Our request logs are kept for up to 7 days.
2929 - **No analytics or advertising.** There are no analytics scripts, tracking pixels or third-party marketing tools on diffinery.com.
30+- **Star on GitHub buttons** are plain links. diffinery.com's server reads the star count from GitHub, so your browser contacts GitHub only if you follow one.
3031
3132 ### Cookies
3233
modifiedapps/diffinery/src/docs/web.tsx+8−1View file
@@ -21,7 +21,8 @@
2121 import { fenceLangs, renderMarkdown } from "../web/markdown.js";
2222 import { fenceHighlighter } from "../web/highlight.js";
2323 import { ICONS } from "../web/assets.js";
24-import { render, type WebApp } from "../web/ui.js";
24+import { render, StarButton, type WebApp } from "../web/ui.js";
25+import { githubStars } from "../web/github-stars.js";
2526 import { pluginZipFor } from "./claude-plugins.js";
2627 import { AGENT_SETUP_SH, CLAUDE_PLUGINS, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
2728 import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js";
@@ -99,6 +100,7 @@
99100 const prev = i > 0 ? getPage(SLUGS[i - 1]!, o) : null;
100101 const next = i >= 0 && i < SLUGS.length - 1 ? getPage(SLUGS[i + 1]!, o) : null;
101102 const toc = p ? headings(p.markdown).filter((h) => h.level === 2 || p.markdown.length < 60_000) : [];
103+ const stars = await githubStars(c);
102104 const body = (
103105 <div class="docs">
104106 <style>{raw(DOCS_CSS)}</style>
@@ -170,6 +172,10 @@
170172 </ul>
171173 </>
172174 ) : null}
175+ <div class="docs-star">
176+ <p>Diffinery is open source. A star on GitHub helps other people find it.</p>
177+ <StarButton stars={stars} compact />
178+ </div>
173179 </aside>
174180 </div>
175181 );
@@ -390,6 +396,7 @@
390396 .docs-toc li a{display:block;padding:3px 0 3px 10px;border-left:2px solid var(--border2);color:var(--fg2);font-size:12.5px;line-height:1.4}
391397 .docs-toc li.l3 a{padding-left:22px}
392398 .docs-toc li a:hover{color:var(--fg);text-decoration:none}.docs-toc li a.on{color:var(--fg);border-left-color:var(--accent)}
399+.docs-star{margin-top:22px;padding-top:16px;border-top:1px solid var(--border2)}.docs-star p{margin:0 0 10px;font-size:12.5px;line-height:1.5;color:var(--fg2)}
393400 .docs-mnav{display:none}
394401 @media (max-width:1180px){.docs{grid-template-columns:232px minmax(0,1fr);gap:32px}.docs-toc{display:none}}
395402 @media (max-width:820px){.docs{grid-template-columns:minmax(0,1fr);gap:0}.docs-side{display:none}
modifiedapps/diffinery/src/web/assets.ts+7−0View file
@@ -87,6 +87,13 @@
8787 .foot-main+.wrap>.foot-bar{border-top:1px solid var(--border2)}.foot-bar nav{display:flex;flex-wrap:wrap;gap:20px}
8888 @media (max-width:1000px){.site-foot .foot-main{grid-template-columns:repeat(4,minmax(0,1fr));padding-top:44px}.foot-brand{grid-column:1/-1}.foot-brand p{max-width:520px}}
8989 @media (max-width:600px){.site-foot .foot-main{grid-template-columns:repeat(2,minmax(0,1fr));gap:32px 16px;padding-top:36px;padding-bottom:36px}.top nav a.nav-site{display:none}}
90+/* "Star on GitHub" (StarButton in ui.tsx): GitHub's own shape, a label and a count split by a rule. */
91+.btn.gh-star .gh-count{display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding-left:9px;align-self:stretch;border-left:1px solid var(--border);color:var(--fg2);font-variant-numeric:tabular-nums}
92+.btn.gh-star.sm .gh-count{margin-left:2px;padding-left:7px}
93+.btn.gh-star .gh-count svg{width:12px;height:12px}
94+.btn.gh-star:hover .gh-count{color:var(--fg)}
95+.top nav .nav-star{margin:0 8px 0 6px}
96+@media (max-width:600px){.top nav .nav-star{margin:0 6px 0 2px}.top nav .nav-star .gh-star-label{display:none}.top nav .nav-star .gh-count{margin-left:0;padding-left:5px}}
9097 /* Controls follow Kumo sizes: base 36px (buttons, inputs, selects), sm 26px. */
9198 .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:36px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 14px/1 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important;vertical-align:middle;flex:none}
9299 .btn svg{flex:none}
addedapps/diffinery/src/web/github-stars.ts+158−0View file
@@ -1,0 +1,158 @@
1+/**
2+ * The star count of Diffinery's GitHub mirror (GITHUB_REPO), for the "Star on GitHub" buttons on the public site.
3+ *
4+ * Fetched by the server, never by the visitor's browser, so no page loads a third-party script or tells GitHub who
5+ * looked at it. Pages never wait for GitHub: a request reads the isolate's copy or the colo's Cache API entry, and
6+ * a stale or missing count is refreshed in the background (the button shows no number until one is known).
7+ * GitHub's REST API allows few unauthenticated requests per IP address, and Workers share theirs, so the count
8+ * stays fresh for 30 minutes and shields.io's JSON badge is the fallback.
9+ */
10+import type { AppContext } from "../auth/principal.js";
11+
12+/** Diffinery's mirror on GitHub (site.tsx's SOURCE.github), where people star the project. */
13+export const GITHUB_REPO = "https://github.com/codician-team/diffinery";
14+
15+export interface Stored {
16+ stars: number;
17+ /** When it was fetched (ms since the epoch). */
18+ at: number;
19+}
20+
21+/** How long a count is used before it is fetched again. */
22+export const FRESH_MS = 30 * 60_000;
23+/** After a failed refresh, how long until the next try. */
24+export const RETRY_MS = 10 * 60_000;
25+/** How long an isolate trusts its own copy before it looks at the colo's cache again. */
26+const LOCAL_MS = 60_000;
27+/** How long the colo keeps a count it couldn't refresh: an old number beats none. */
28+const KEEP_S = 7 * 86400;
29+
30+const slug = new URL(GITHUB_REPO).pathname.slice(1);
31+
32+type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
33+
34+/** The count from GitHub's REST API, else from shields.io's badge (which rounds large counts, e.g. "1.2k"); null if neither answers. */
35+export async function fetchGitHubStars(f: Fetch = fetch): Promise<number | null> {
36+ const signal = () => AbortSignal.timeout(5000);
37+ try {
38+ const r = await f(`https://api.github.com/repos/${slug}`, {
39+ headers: { accept: "application/vnd.github+json", "user-agent": "Diffinery (https://diffinery.com)", "x-github-api-version": "2022-11-28" },
40+ signal: signal(),
41+ });
42+ if (r.ok) {
43+ const n = ((await r.json()) as { stargazers_count?: unknown }).stargazers_count;
44+ if (typeof n === "number" && Number.isInteger(n) && n >= 0) return n;
45+ }
46+ } catch {
47+ // Try the badge.
48+ }
49+ try {
50+ const r = await f(`https://img.shields.io/github/stars/${slug}.json`, { headers: { "user-agent": "Diffinery (https://diffinery.com)" }, signal: signal() });
51+ if (r.ok) return parseBadgeCount(((await r.json()) as { message?: unknown }).message);
52+ } catch {
53+ // Neither answered.
54+ }
55+ return null;
56+}
57+
58+/** "30" -> 30, "1.2k" -> 1200, "3M" -> 3000000; anything else (such as "repo not found") -> null. */
59+export function parseBadgeCount(message: unknown): number | null {
60+ const m = typeof message === "string" ? /^(\d+(?:\.\d+)?)([kM]?)$/.exec(message.trim()) : null;
61+ if (!m) return null;
62+ return Math.round(Number(m[1]) * (m[2] === "k" ? 1e3 : m[2] === "M" ? 1e6 : 1));
63+}
64+
65+/** A count as GitHub shows it on its Star button: 30, 999, 1k, 1.2k, 12.3k. */
66+export function formatStars(n: number): string {
67+ if (n < 1000) return String(n);
68+ if (n < 1e6) return `${trim(n / 1e3)}k`;
69+ return `${trim(n / 1e6)}M`;
70+}
71+const trim = (x: number) => (x >= 100 ? String(Math.floor(x)) : (Math.floor(x * 10) / 10).toFixed(1).replace(/\.0$/, ""));
72+
73+export interface Store {
74+ get(): Promise<Stored | null>;
75+ put(s: Stored): Promise<void>;
76+}
77+
78+/** Keeps the count for a colo, under this instance's own origin, so no other site's entries are touched. */
79+function edgeStore(origin: string): Store | null {
80+ const cache = typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default;
81+ if (!cache) return null;
82+ const key = new Request(`${origin}/-/cache/github-stars/${slug}`);
83+ return {
84+ async get() {
85+ const r = await cache.match(key).catch(() => undefined);
86+ const s = r ? ((await r.json().catch(() => null)) as Stored | null) : null;
87+ return s && typeof s.stars === "number" && typeof s.at === "number" ? s : null;
88+ },
89+ async put(s) {
90+ await cache.put(key, new Response(JSON.stringify(s), { headers: { "content-type": "application/json", "cache-control": `public, max-age=${KEEP_S}` } })).catch(() => undefined);
91+ },
92+ };
93+}
94+
95+/** One per isolate: the last count it saw, and the refresh it is running. */
96+export class StarCount {
97+ private local: (Stored & { seen: number }) | null = null;
98+ private refreshing: Promise<void> | null = null;
99+ /** When it last asked GitHub, so a failure with no count to keep isn't retried on every request. */
100+ private tried = -Infinity;
101+
102+ constructor(
103+ private readonly fetchStars: () => Promise<number | null> = () => fetchGitHubStars(),
104+ private readonly clock: () => number = Date.now,
105+ ) {}
106+
107+ /** The count to show now, or null; starts a refresh when the count is old or unknown. Never waits on GitHub. */
108+ async read(store: Store, background: (p: Promise<void>) => void): Promise<number | null> {
109+ const now = this.clock();
110+ if (!this.local || now - this.local.seen >= LOCAL_MS) {
111+ const s = await store.get();
112+ if (s && (!this.local || s.at >= this.local.at)) this.local = { ...s, seen: now };
113+ else if (this.local) this.local.seen = now;
114+ }
115+ if ((!this.local || now - this.local.at >= FRESH_MS) && !this.refreshing && now - this.tried >= RETRY_MS) {
116+ this.tried = now;
117+ const p = this.refresh(store).finally(() => (this.refreshing = null));
118+ this.refreshing = p;
119+ background(p);
120+ }
121+ return this.local?.stars ?? null;
122+ }
123+
124+ private async refresh(store: Store): Promise<void> {
125+ const now = this.clock();
126+ const stars = await this.fetchStars().catch(() => null);
127+ // A failure keeps the last count and tries again in RETRY_MS, here and in every other isolate of the colo.
128+ const s: Stored | null = stars !== null ? { stars, at: now } : this.local ? { stars: this.local.stars, at: now - FRESH_MS + RETRY_MS } : null;
129+ if (!s) return;
130+ this.local = { ...s, seen: now };
131+ await store.put(s);
132+ }
133+}
134+
135+const isolate = new StarCount();
136+const perRequest = new WeakMap<Request, Promise<number | null>>();
137+
138+/**
139+ * The mirror's star count for this request (looked up once however many buttons the page has), or null while unknown.
140+ * Without the Cache API (unit tests, workers.dev) there is nowhere to keep a count, so GitHub isn't asked.
141+ */
142+export function githubStars(c: AppContext): Promise<number | null> {
143+ let p = perRequest.get(c.req.raw);
144+ if (!p) {
145+ const store = edgeStore(new URL(c.req.url).origin);
146+ if (!store) return Promise.resolve(null);
147+ const background = (q: Promise<void>) => {
148+ try {
149+ c.executionCtx.waitUntil(q);
150+ } catch {
151+ // No execution context (tests): the refresh finishes on its own.
152+ }
153+ };
154+ p = isolate.read(store, background).catch(() => null);
155+ perRequest.set(c.req.raw, p);
156+ }
157+ return p;
158+}
modifiedapps/diffinery/src/web/icons.ui.generated.ts+2−0View file
@@ -56,4 +56,6 @@
5656 flag: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z\"></path></svg>",
5757 prohibit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.56,87.56,0,0,1-20.41,56.28L71.72,60.4A88,88,0,0,1,216,128ZM40,128A87.56,87.56,0,0,1,60.41,71.72L184.28,195.6A88,88,0,0,1,40,128Z\"></path></svg>",
5858 eyeSlash: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z\"></path></svg>",
59+ github: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216,104v8a56.06,56.06,0,0,1-48.44,55.47A39.8,39.8,0,0,1,176,192v40a8,8,0,0,1-8,8H104a8,8,0,0,1-8-8V216H72a40,40,0,0,1-40-40A24,24,0,0,0,8,152a8,8,0,0,1,0-16,40,40,0,0,1,40,40,24,24,0,0,0,24,24H96v-8a39.8,39.8,0,0,1,8.44-24.53A56.06,56.06,0,0,1,56,112v-8a58.14,58.14,0,0,1,7.69-28.32A59.78,59.78,0,0,1,69.07,28,8,8,0,0,1,76,24a59.75,59.75,0,0,1,48,24h24a59.75,59.75,0,0,1,48-24,8,8,0,0,1,6.93,4,59.74,59.74,0,0,1,5.37,47.68A58,58,0,0,1,216,104Z\"></path></svg>",
60+ star: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z\"></path></svg>",
5961 } as const;
modifiedapps/diffinery/src/web/site.tsx+21−6View file
@@ -15,10 +15,11 @@
1515 import { SITE_NAV, SITE_PAGES } from "../docs/content.generated.js";
1616 import { imageProxy } from "../lib/camo.js";
1717 import { ICONS } from "./assets.js";
18+import { GITHUB_REPO, githubStars } from "./github-stars.js";
1819 import { fenceHighlighter } from "./highlight.js";
1920 import { escapeHtml, fenceLangs, renderMarkdown } from "./markdown.js";
2021 import { SITE } from "./seo.js";
21-import { render, type WebApp } from "./ui.js";
22+import { render, StarButton, type WebApp } from "./ui.js";
2223
2324 export interface SitePageInfo {
2425 /** URL path, e.g. "/compare/github". */
@@ -258,7 +259,7 @@
258259 /** Where Diffinery's source lives, whatever instance serves this page, and its license. */
259260 export const SOURCE = {
260261 repo: "https://diffinery.com/codician/diffinery",
261- github: "https://github.com/codician-team/diffinery",
262+ github: GITHUB_REPO,
262263 license: "https://www.apache.org/licenses/LICENSE-2.0",
263264 spdx: "Apache-2.0",
264265 };
@@ -371,8 +372,8 @@
371372 /** Inline Markdown (links and code) of a home page answer, as HTML. */
372373 const inline = (md: string) => renderMarkdown(md, { idPrefix: "" }).replace(/^<p>|<\/p>\s*$/g, "");
373374
374-/** The home page for visitors. */
375-export const Landing: FC = () => (
375+/** The home page for visitors. `stars` is the GitHub mirror's star count, left out where the page's copy is compared (homeContent). */
376+export const Landing: FC<{ stars?: number | null }> = ({ stars }) => (
376377 <div class="mk">
377378 <style>{raw(MARKETING_CSS)}</style>
378379 <section class="hero">
@@ -383,6 +384,7 @@
383384 <p>{HOME.lead}</p>
384385 <p class="row" style="justify-content:center">
385386 <Cta />
387+ {stars === undefined ? null : <StarButton stars={stars} />}
386388 </p>
387389 <p class="muted small">
388390 Free, with nothing to buy: you run Diffinery on your own Cloudflare account. Want to try it first? <a href={EARLY_ACCESS}>Request early access</a> to diffinery.com. Source on <a href={SOURCE.repo}>Diffinery</a> and <a href={SOURCE.github}>GitHub</a>.
@@ -515,9 +517,10 @@
515517 );
516518
517519 /** The home page for signed-out visitors (registerAccount calls this for "/"). */
518-export function landingPage(c: AppContext): Response | Promise<Response> {
520+export async function landingPage(c: AppContext): Promise<Response> {
519521 const o = origin(c);
520522 if (wantsMarkdown(c)) return markdownResponse(c, homeMarkdown(o));
523+ const stars = await githubStars(c);
521524 c.header("link", `<${o}/index.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`);
522525 c.header("vary", "Accept");
523526 return render(
@@ -530,7 +533,7 @@
530533 jsonLd: [...entities(o), { "@type": "WebPage", "@id": `${o}/#webpage`, url: `${o}/`, name: HOME.title, description: HOME.description, isPartOf: { "@id": ids(o).site }, about: { "@id": ids(o).app }, dateModified: HOME_UPDATED, inLanguage: "en" }, ...faqPage(`${o}/`, HOME.faq)],
531534 head: <link rel="alternate" type="text/markdown" href="/index.md" title="Diffinery (Markdown)" />,
532535 },
533- <Landing />,
536+ <Landing stars={stars} />,
534537 );
535538 }
536539
@@ -566,6 +569,9 @@
566569
567570 async function sitePageResponse(c: AppContext, p: SitePageInfo): Promise<Response> {
568571 const o = origin(c);
572+ // The Open source page leads with where to get the source, and a Star button for the mirror.
573+ const source = p.path === "/open-source";
574+ const stars = source ? await githubStars(c) : null;
569575 const url = `${o}${p.path}`;
570576 const html = await pageHtml(c, p);
571577 const id = ids(o);
@@ -612,6 +618,14 @@
612618 </nav>
613619 <h1>{p.title}</h1>
614620 <p class="mk-lead">{p.description}</p>
621+ {source ? (
622+ <p class="row mk-source">
623+ <a class="btn" href={SOURCE.repo}>
624+ {raw(ICONS.code)} View the source
625+ </a>
626+ <StarButton stars={stars} />
627+ </p>
628+ ) : null}
615629 <p class="mk-meta muted small">
616630 {isPost(p) && p.published && p.published !== p.updated ? (
617631 <>
@@ -724,6 +738,7 @@
724738 .mk-band p{margin:0 auto 14px;max-width:620px}
725739 .mk-page{max-width:880px;margin:0 auto}
726740 .mk-head{padding:28px 0 18px;border-bottom:1px solid var(--border2);margin-bottom:8px}
741+.mk-source{margin:0 0 14px}
727742 .mk-head h1{font-size:36px;line-height:1.15;letter-spacing:-.025em;margin:8px 0 10px}
728743 .mk-crumbs{font-size:13px;min-height:18px}
729744 .mk-lead{font-size:18px;color:var(--fg2);margin:0 0 10px;line-height:1.55}
modifiedapps/diffinery/src/web/ui.tsx+35−6View file
@@ -21,6 +21,7 @@
2121 import { avatarPath } from "../http/avatars.js";
2222 import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js";
2323 import { imageProxy } from "../lib/camo.js";
24+import { formatStars, GITHUB_REPO, githubStars } from "./github-stars.js";
2425
2526 export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
2627 export type Stub = ReturnType<typeof repoStub>;
@@ -432,8 +433,30 @@
432433 return v === "light" || v === "dark" ? v : null;
433434 }
434435
436+/**
437+ * "Star on GitHub", with the mirror's star count once the server knows it (web/github-stars.ts). A plain link to
438+ * GitHub: no script, nothing loaded from GitHub. `compact` (the header) says only "Star", and only the logo and
439+ * count on phones.
440+ */
441+export const StarButton: FC<{ stars: number | null; compact?: boolean; class?: string }> = ({ stars, compact, class: cls }) => {
442+ const count = stars === null ? null : formatStars(stars);
443+ const label = `Star Diffinery on GitHub${stars === null ? "" : ` (${stars.toLocaleString("en-US")} ${stars === 1 ? "star" : "stars"})`}`;
444+ return (
445+ <a href={GITHUB_REPO} class={`btn gh-star${compact ? " sm compact" : ""}${cls ? ` ${cls}` : ""}`} aria-label={label} title={label}>
446+ {raw(ICONS.github)}
447+ <span class="gh-star-label">{compact ? "Star" : "Star on GitHub"}</span>
448+ {count ? (
449+ <span class="gh-count">
450+ {raw(ICONS.star)}
451+ {count}
452+ </span>
453+ ) : null}
454+ </a>
455+ );
456+};
457+
435458 /** Links to the public site for visitors: every page reachable from every page, for people and crawlers. */
436-const SiteFooter: FC = () => (
459+const SiteFooter: FC<{ stars: number | null }> = ({ stars }) => (
437460 <div class="wrap foot-main">
438461 <div class="foot-brand">
439462 <a class="brand" href="/" aria-label="Diffinery home">
@@ -442,15 +465,18 @@
442465 <p>
443466 <strong>{SITE.tagline}.</strong> Open-source repositories, pull requests and GitHub Actions–compatible CI for teams of people and AI agents, on your own Cloudflare account.
444467 </p>
445- <a href="/docs/self-hosting" class="btn sm">
446- Deploy your own
447- </a>
468+ <div class="row">
469+ <a href="/docs/self-hosting" class="btn sm">
470+ Deploy your own
471+ </a>
472+ <StarButton stars={stars} compact />
473+ </div>
448474 </div>
449475 {[
450476 { title: "Product", links: [["/features", "Features"], ["/agents", "For AI agents"], ["/performance", "Performance"], ["/security", "Security"], ["/built-on-cloudflare", "Built on Cloudflare"], ["/open-source", "Open source"], ["/docs/migrate-from-github", "Migrate from GitHub"]] },
451477 { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] },
452478 { title: "Resources", links: [["/docs", "Docs"], ["/docs/self-hosting", "Self-hosting"], ["/docs/guides", "Guides"], ["/docs/connect", "Connect AI apps"], ["/llms.txt", "llms.txt"], ["/status", "Status"], ["/docs/security/disclosure", "Report a vulnerability"]] },
453- { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], ["https://github.com/codician-team/diffinery", "GitHub mirror"], ["/privacy", "Privacy"]] },
479+ { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/privacy", "Privacy"]] },
454480 ].map((col) => (
455481 <nav aria-label={col.title}>
456482 <h2>{col.title}</h2>
@@ -477,6 +503,8 @@
477503 const p = c.get("principal");
478504 const me = p?.type === "account" ? p.account : null;
479505 const active = me?.status === "active";
506+ // Visitors get a Star button in the header and footer; signed-in people's pages stay as they are.
507+ const stars = active ? null : await githubStars(c);
480508 c.header("content-security-policy", csp(nonce, c, opts.turnstile, opts.formAction));
481509 c.header("x-frame-options", "DENY");
482510 c.header("x-content-type-options", "nosniff");
@@ -546,6 +574,7 @@
546574 <a href="/docs" title="Documentation">
547575 Docs
548576 </a>
577+ {active ? null : <StarButton stars={stars} compact class="nav-star" />}
549578 {active && me ? (
550579 <>
551580 <a href="/new" title="New repository">
@@ -581,7 +610,7 @@
581610 {raw(content)}
582611 </main>
583612 <footer class="site-foot">
584- {active ? null : <SiteFooter />}
613+ {active ? null : <SiteFooter stars={stars} />}
585614 <div class="wrap">
586615 <div class="foot-bar">
587616 <span>© {new Date().getUTCFullYear()} Diffinery</span>
addedapps/diffinery/test/unit/github-stars.test.ts+123−0View file
@@ -1,0 +1,123 @@
1+/**
2+ * The GitHub mirror's star count behind the site's "Star on GitHub" buttons (web/github-stars.ts): pages never wait
3+ * on GitHub, a count is shared through the colo's cache, failures keep the last count, and the numbers read like
4+ * GitHub's own.
5+ */
6+import { describe, expect, it } from "vitest";
7+import { FRESH_MS, RETRY_MS, StarCount, fetchGitHubStars, formatStars, parseBadgeCount, type Store, type Stored } from "../../src/web/github-stars.js";
8+
9+function memoryStore(initial: Stored | null = null): Store & { value: Stored | null } {
10+ const s = {
11+ value: initial,
12+ get: async () => s.value,
13+ put: async (v: Stored) => void (s.value = v),
14+ };
15+ return s;
16+}
17+
18+/** A StarCount with a hand-driven clock and GitHub, collecting its background refreshes. */
19+function setup(answers: Array<number | null>) {
20+ let now = 1_000_000;
21+ const asked: number[] = [];
22+ const count = new StarCount(
23+ async () => {
24+ asked.push(now);
25+ return answers.shift() ?? null;
26+ },
27+ () => now,
28+ );
29+ const pending: Promise<void>[] = [];
30+ const read = (store: Store) => count.read(store, (p) => pending.push(p));
31+ const settle = () => Promise.all(pending.splice(0));
32+ return { read, settle, asked, tick: (ms: number) => (now += ms), now: () => now };
33+}
34+
35+describe("star count", () => {
36+ it("shows nothing until GitHub answers, without waiting for it", async () => {
37+ const t = setup([30]);
38+ const store = memoryStore();
39+ expect(await t.read(store)).toBeNull();
40+ await t.settle();
41+ expect(store.value).toEqual({ stars: 30, at: t.now() });
42+ expect(await t.read(store)).toBe(30);
43+ expect(t.asked).toHaveLength(1);
44+ });
45+
46+ it("uses another isolate's count from the colo's cache, and refreshes it once it is old", async () => {
47+ const t = setup([31]);
48+ const store = memoryStore({ stars: 29, at: 1_000_000 });
49+ expect(await t.read(store)).toBe(29);
50+ await t.settle();
51+ expect(t.asked).toHaveLength(0);
52+ t.tick(FRESH_MS);
53+ expect(await t.read(store)).toBe(29);
54+ expect(await t.read(store)).toBe(29);
55+ await t.settle();
56+ expect(t.asked).toHaveLength(1);
57+ expect(await t.read(store)).toBe(31);
58+ });
59+
60+ it("keeps the last count when GitHub fails, and tries again later rather than on every request", async () => {
61+ const t = setup([null, 40]);
62+ const store = memoryStore({ stars: 35, at: 1_000_000 - FRESH_MS });
63+ expect(await t.read(store)).toBe(35);
64+ await t.settle();
65+ expect(t.asked).toHaveLength(1);
66+ t.tick(RETRY_MS - 1);
67+ expect(await t.read(store)).toBe(35);
68+ await t.settle();
69+ expect(t.asked).toHaveLength(1);
70+ t.tick(1);
71+ await t.read(store);
72+ await t.settle();
73+ expect(t.asked).toHaveLength(2);
74+ expect(await t.read(store)).toBe(40);
75+ });
76+
77+ it("doesn't retry on every request when it has never had a count", async () => {
78+ const t = setup([null, null]);
79+ const store = memoryStore();
80+ for (let i = 0; i < 5; i++) {
81+ expect(await t.read(store)).toBeNull();
82+ await t.settle();
83+ }
84+ expect(t.asked).toHaveLength(1);
85+ });
86+});
87+
88+describe("fetching", () => {
89+ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
90+
91+ it("reads stargazers_count from GitHub's REST API", async () => {
92+ const urls: string[] = [];
93+ const n = await fetchGitHubStars(async (u) => (urls.push(u), json({ stargazers_count: 29 })));
94+ expect(n).toBe(29);
95+ expect(urls).toEqual(["https://api.github.com/repos/codician-team/diffinery"]);
96+ });
97+
98+ it("falls back to shields.io's badge when GitHub refuses, as it does when the IP address is rate limited", async () => {
99+ const urls: string[] = [];
100+ const n = await fetchGitHubStars(async (u) => (urls.push(u), u.startsWith("https://api.github.com/") ? json({ message: "API rate limit exceeded" }, 403) : json({ label: "stars", message: "1.2k" })));
101+ expect(n).toBe(1200);
102+ expect(urls[1]).toBe("https://img.shields.io/github/stars/codician-team/diffinery.json");
103+ });
104+
105+ it("gives null when neither answers", async () => {
106+ expect(await fetchGitHubStars(async () => Promise.reject(new Error("offline")))).toBeNull();
107+ expect(await fetchGitHubStars(async (u) => (u.includes("shields") ? json({ message: "repo not found" }) : json({}, 404)))).toBeNull();
108+ });
109+});
110+
111+describe("numbers", () => {
112+ it("parses shields.io's counts", () => {
113+ expect(parseBadgeCount("30")).toBe(30);
114+ expect(parseBadgeCount("1.2k")).toBe(1200);
115+ expect(parseBadgeCount("3M")).toBe(3_000_000);
116+ expect(parseBadgeCount("invalid")).toBeNull();
117+ expect(parseBadgeCount(undefined)).toBeNull();
118+ });
119+
120+ it("formats them as GitHub's Star button does", () => {
121+ expect([0, 29, 999, 1000, 1049, 1234, 12_345, 123_456, 1_250_000].map(formatStars)).toEqual(["0", "29", "999", "1k", "1k", "1.2k", "12.3k", "123k", "1.2M"]);
122+ });
123+});
modifiedapps/diffinery/test/unit/site.test.ts+14−1View file
@@ -11,7 +11,7 @@
1111 import type { Env } from "../../src/env.js";
1212 import { DOCS_IMAGES } from "../../src/docs/content.generated.js";
1313 import { getPage, llmsFullTxt, llmsTxt } from "../../src/docs/pages.js";
14-import { CLOUDFLARE_CLAIM, faqs, homeMarkdown, sitePage, sitePages } from "../../src/web/site.js";
14+import { CLOUDFLARE_CLAIM, faqs, homeContent, homeMarkdown, sitePage, sitePages } from "../../src/web/site.js";
1515 import { RESERVED_LOGINS } from "../../src/lib/util.js";
1616
1717 app.route("/", web);
@@ -179,6 +179,19 @@
179179 expect(await (await get("/")).text()).not.toContain("/-/kumo/");
180180 });
181181
182+ it("asks visitors to star the GitHub mirror with plain links, loading nothing from GitHub", async () => {
183+ const stars = (html: string) => html.match(/<a href="https:\/\/github\.com\/codician-team\/diffinery" class="btn gh-star[^"]*"/g) ?? [];
184+ const home = await (await get("/")).text();
185+ // The header, the hero and the footer.
186+ expect(stars(home)).toHaveLength(3);
187+ expect(home).not.toMatch(/<script[^>]+src="https:\/\/[^"]*(github|shields)/);
188+ expect(home).not.toMatch(/<img[^>]+src="https:\/\/(img\.shields\.io|api\.github\.com)/);
189+ expect(stars(await (await get("/open-source")).text())).toHaveLength(3);
190+ expect(stars(await (await get("/docs")).text())).toHaveLength(3);
191+ // The copy IndexNow compares has no count, so a new star doesn't count as a changed page.
192+ expect(await homeContent(ORIGIN)).not.toContain("gh-star");
193+ });
194+
182195 it("is served as Markdown", async () => {
183196 for (const res of [await get("/index.md"), await get("/", { accept: "text/markdown" })]) {
184197 expect(res.status).toBe(200);