Merge 98a55ab33f1c7e683660b9d2d15a25324a51a396 into 24957a35448ab398d90b11efb186a032bc51ada7
Diffinery authored 2 parents 24957a398a55abcommit 4da7593d33d5a6e5ebad04dcdef0a5f0f637dc0bBrowse files
11 files changed+374−16
Files (11)
- README.md+2−1
- apps/diffinery/scripts/make-ui-icons.mjs+2−0
- apps/diffinery/site/privacy.md+2−1
- apps/diffinery/src/docs/web.tsx+8−1
- apps/diffinery/src/web/assets.ts+7−0
- apps/diffinery/src/web/github-stars.ts+158−0
- apps/diffinery/src/web/icons.ui.generated.ts+2−0
- apps/diffinery/src/web/site.tsx+21−6
- apps/diffinery/src/web/ui.tsx+35−6
- apps/diffinery/test/unit/github-stars.test.ts+123−0
- apps/diffinery/test/unit/site.test.ts+14−1
modifiedREADME.md+2−1View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | **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. |
| 4 | 4 | |
| 5 | +[](https://github.com/codician-team/diffinery) | |
| 5 | 6 | [](LICENSE) |
| 6 | 7 | [](https://diffinery.com/docs/actions/compatibility) |
| 7 | 8 | [](https://diffinery.com/built-on-cloudflare) |
| @@ -21,7 +22,7 @@ | ||
| 21 | 22 | - **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). |
| 22 | 23 | - **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. |
| 23 | 24 | |
| 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. | |
| 25 | 26 | |
| 26 | 27 | ## Contents |
| 27 | 28 |
modifiedapps/diffinery/scripts/make-ui-icons.mjs+2−0View file
| @@ -63,6 +63,8 @@ | ||
| 63 | 63 | flag: ["Flag", "regular"], |
| 64 | 64 | prohibit: ["Prohibit", "regular"], |
| 65 | 65 | eyeSlash: ["EyeSlash", "regular"], |
| 66 | + github: ["GithubLogo", "fill"], | |
| 67 | + star: ["Star", "regular"], | |
| 66 | 68 | }; |
| 67 | 69 | |
| 68 | 70 | const out = {}; |
modifiedapps/diffinery/site/privacy.md+2−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | title: Privacy policy |
| 3 | 3 | seo_title: "Privacy policy · Diffinery" |
| 4 | 4 | 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 | |
| 6 | 6 | --- |
| 7 | 7 | |
| 8 | 8 | 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 @@ | ||
| 27 | 27 | |
| 28 | 28 | - **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. |
| 29 | 29 | - **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. | |
| 30 | 31 | |
| 31 | 32 | ### Cookies |
| 32 | 33 |
modifiedapps/diffinery/src/docs/web.tsx+8−1View file
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | import { fenceLangs, renderMarkdown } from "../web/markdown.js"; |
| 22 | 22 | import { fenceHighlighter } from "../web/highlight.js"; |
| 23 | 23 | 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"; | |
| 25 | 26 | import { pluginZipFor } from "./claude-plugins.js"; |
| 26 | 27 | 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"; |
| 27 | 28 | import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js"; |
| @@ -99,6 +100,7 @@ | ||
| 99 | 100 | const prev = i > 0 ? getPage(SLUGS[i - 1]!, o) : null; |
| 100 | 101 | const next = i >= 0 && i < SLUGS.length - 1 ? getPage(SLUGS[i + 1]!, o) : null; |
| 101 | 102 | const toc = p ? headings(p.markdown).filter((h) => h.level === 2 || p.markdown.length < 60_000) : []; |
| 103 | + const stars = await githubStars(c); | |
| 102 | 104 | const body = ( |
| 103 | 105 | <div class="docs"> |
| 104 | 106 | <style>{raw(DOCS_CSS)}</style> |
| @@ -170,6 +172,10 @@ | ||
| 170 | 172 | </ul> |
| 171 | 173 | </> |
| 172 | 174 | ) : 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> | |
| 173 | 179 | </aside> |
| 174 | 180 | </div> |
| 175 | 181 | ); |
| @@ -390,6 +396,7 @@ | ||
| 390 | 396 | .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} |
| 391 | 397 | .docs-toc li.l3 a{padding-left:22px} |
| 392 | 398 | .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)} | |
| 393 | 400 | .docs-mnav{display:none} |
| 394 | 401 | @media (max-width:1180px){.docs{grid-template-columns:232px minmax(0,1fr);gap:32px}.docs-toc{display:none}} |
| 395 | 402 | @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 @@ | ||
| 87 | 87 | .foot-main+.wrap>.foot-bar{border-top:1px solid var(--border2)}.foot-bar nav{display:flex;flex-wrap:wrap;gap:20px} |
| 88 | 88 | @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}} |
| 89 | 89 | @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}} | |
| 90 | 97 | /* Controls follow Kumo sizes: base 36px (buttons, inputs, selects), sm 26px. */ |
| 91 | 98 | .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} |
| 92 | 99 | .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 @@ | ||
| 56 | 56 | 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>", |
| 57 | 57 | 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>", |
| 58 | 58 | 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>", | |
| 59 | 61 | } as const; |
modifiedapps/diffinery/src/web/site.tsx+21−6View file
| @@ -15,10 +15,11 @@ | ||
| 15 | 15 | import { SITE_NAV, SITE_PAGES } from "../docs/content.generated.js"; |
| 16 | 16 | import { imageProxy } from "../lib/camo.js"; |
| 17 | 17 | import { ICONS } from "./assets.js"; |
| 18 | +import { GITHUB_REPO, githubStars } from "./github-stars.js"; | |
| 18 | 19 | import { fenceHighlighter } from "./highlight.js"; |
| 19 | 20 | import { escapeHtml, fenceLangs, renderMarkdown } from "./markdown.js"; |
| 20 | 21 | import { SITE } from "./seo.js"; |
| 21 | -import { render, type WebApp } from "./ui.js"; | |
| 22 | +import { render, StarButton, type WebApp } from "./ui.js"; | |
| 22 | 23 | |
| 23 | 24 | export interface SitePageInfo { |
| 24 | 25 | /** URL path, e.g. "/compare/github". */ |
| @@ -258,7 +259,7 @@ | ||
| 258 | 259 | /** Where Diffinery's source lives, whatever instance serves this page, and its license. */ |
| 259 | 260 | export const SOURCE = { |
| 260 | 261 | repo: "https://diffinery.com/codician/diffinery", |
| 261 | - github: "https://github.com/codician-team/diffinery", | |
| 262 | + github: GITHUB_REPO, | |
| 262 | 263 | license: "https://www.apache.org/licenses/LICENSE-2.0", |
| 263 | 264 | spdx: "Apache-2.0", |
| 264 | 265 | }; |
| @@ -371,8 +372,8 @@ | ||
| 371 | 372 | /** Inline Markdown (links and code) of a home page answer, as HTML. */ |
| 372 | 373 | const inline = (md: string) => renderMarkdown(md, { idPrefix: "" }).replace(/^<p>|<\/p>\s*$/g, ""); |
| 373 | 374 | |
| 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 }) => ( | |
| 376 | 377 | <div class="mk"> |
| 377 | 378 | <style>{raw(MARKETING_CSS)}</style> |
| 378 | 379 | <section class="hero"> |
| @@ -383,6 +384,7 @@ | ||
| 383 | 384 | <p>{HOME.lead}</p> |
| 384 | 385 | <p class="row" style="justify-content:center"> |
| 385 | 386 | <Cta /> |
| 387 | + {stars === undefined ? null : <StarButton stars={stars} />} | |
| 386 | 388 | </p> |
| 387 | 389 | <p class="muted small"> |
| 388 | 390 | 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 @@ | ||
| 515 | 517 | ); |
| 516 | 518 | |
| 517 | 519 | /** 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> { | |
| 519 | 521 | const o = origin(c); |
| 520 | 522 | if (wantsMarkdown(c)) return markdownResponse(c, homeMarkdown(o)); |
| 523 | + const stars = await githubStars(c); | |
| 521 | 524 | c.header("link", `<${o}/index.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`); |
| 522 | 525 | c.header("vary", "Accept"); |
| 523 | 526 | return render( |
| @@ -530,7 +533,7 @@ | ||
| 530 | 533 | 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)], |
| 531 | 534 | head: <link rel="alternate" type="text/markdown" href="/index.md" title="Diffinery (Markdown)" />, |
| 532 | 535 | }, |
| 533 | - <Landing />, | |
| 536 | + <Landing stars={stars} />, | |
| 534 | 537 | ); |
| 535 | 538 | } |
| 536 | 539 | |
| @@ -566,6 +569,9 @@ | ||
| 566 | 569 | |
| 567 | 570 | async function sitePageResponse(c: AppContext, p: SitePageInfo): Promise<Response> { |
| 568 | 571 | 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; | |
| 569 | 575 | const url = `${o}${p.path}`; |
| 570 | 576 | const html = await pageHtml(c, p); |
| 571 | 577 | const id = ids(o); |
| @@ -612,6 +618,14 @@ | ||
| 612 | 618 | </nav> |
| 613 | 619 | <h1>{p.title}</h1> |
| 614 | 620 | <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} | |
| 615 | 629 | <p class="mk-meta muted small"> |
| 616 | 630 | {isPost(p) && p.published && p.published !== p.updated ? ( |
| 617 | 631 | <> |
| @@ -724,6 +738,7 @@ | ||
| 724 | 738 | .mk-band p{margin:0 auto 14px;max-width:620px} |
| 725 | 739 | .mk-page{max-width:880px;margin:0 auto} |
| 726 | 740 | .mk-head{padding:28px 0 18px;border-bottom:1px solid var(--border2);margin-bottom:8px} |
| 741 | +.mk-source{margin:0 0 14px} | |
| 727 | 742 | .mk-head h1{font-size:36px;line-height:1.15;letter-spacing:-.025em;margin:8px 0 10px} |
| 728 | 743 | .mk-crumbs{font-size:13px;min-height:18px} |
| 729 | 744 | .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 @@ | ||
| 21 | 21 | import { avatarPath } from "../http/avatars.js"; |
| 22 | 22 | import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js"; |
| 23 | 23 | import { imageProxy } from "../lib/camo.js"; |
| 24 | +import { formatStars, GITHUB_REPO, githubStars } from "./github-stars.js"; | |
| 24 | 25 | |
| 25 | 26 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| 26 | 27 | export type Stub = ReturnType<typeof repoStub>; |
| @@ -432,8 +433,30 @@ | ||
| 432 | 433 | return v === "light" || v === "dark" ? v : null; |
| 433 | 434 | } |
| 434 | 435 | |
| 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 | + | |
| 435 | 458 | /** 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 }) => ( | |
| 437 | 460 | <div class="wrap foot-main"> |
| 438 | 461 | <div class="foot-brand"> |
| 439 | 462 | <a class="brand" href="/" aria-label="Diffinery home"> |
| @@ -442,15 +465,18 @@ | ||
| 442 | 465 | <p> |
| 443 | 466 | <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. |
| 444 | 467 | </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> | |
| 448 | 474 | </div> |
| 449 | 475 | {[ |
| 450 | 476 | { 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"]] }, |
| 451 | 477 | { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] }, |
| 452 | 478 | { 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"]] }, | |
| 454 | 480 | ].map((col) => ( |
| 455 | 481 | <nav aria-label={col.title}> |
| 456 | 482 | <h2>{col.title}</h2> |
| @@ -477,6 +503,8 @@ | ||
| 477 | 503 | const p = c.get("principal"); |
| 478 | 504 | const me = p?.type === "account" ? p.account : null; |
| 479 | 505 | 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); | |
| 480 | 508 | c.header("content-security-policy", csp(nonce, c, opts.turnstile, opts.formAction)); |
| 481 | 509 | c.header("x-frame-options", "DENY"); |
| 482 | 510 | c.header("x-content-type-options", "nosniff"); |
| @@ -546,6 +574,7 @@ | ||
| 546 | 574 | <a href="/docs" title="Documentation"> |
| 547 | 575 | Docs |
| 548 | 576 | </a> |
| 577 | + {active ? null : <StarButton stars={stars} compact class="nav-star" />} | |
| 549 | 578 | {active && me ? ( |
| 550 | 579 | <> |
| 551 | 580 | <a href="/new" title="New repository"> |
| @@ -581,7 +610,7 @@ | ||
| 581 | 610 | {raw(content)} |
| 582 | 611 | </main> |
| 583 | 612 | <footer class="site-foot"> |
| 584 | - {active ? null : <SiteFooter />} | |
| 613 | + {active ? null : <SiteFooter stars={stars} />} | |
| 585 | 614 | <div class="wrap"> |
| 586 | 615 | <div class="foot-bar"> |
| 587 | 616 | <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 @@ | ||
| 11 | 11 | import type { Env } from "../../src/env.js"; |
| 12 | 12 | import { DOCS_IMAGES } from "../../src/docs/content.generated.js"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { RESERVED_LOGINS } from "../../src/lib/util.js"; |
| 16 | 16 | |
| 17 | 17 | app.route("/", web); |
| @@ -179,6 +179,19 @@ | ||
| 179 | 179 | expect(await (await get("/")).text()).not.toContain("/-/kumo/"); |
| 180 | 180 | }); |
| 181 | 181 | |
| 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 | + | |
| 182 | 195 | it("is served as Markdown", async () => { |
| 183 | 196 | for (const res of [await get("/index.md"), await get("/", { accept: "text/markdown" })]) { |
| 184 | 197 | expect(res.status).toBe(200); |