| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | /** |
| 3 | * The public site around the product: the home page for visitors (Landing), and the product, |
| 4 | * comparison and company pages written as Markdown in site/ (built into content.generated.ts by |
| 5 | * scripts/build-docs.mjs), served at /features, /compare/github and so on. |
| 6 | * |
| 7 | * Written for people, search engines and AI assistants alike. Every page is also served as Markdown |
| 8 | * (/<page>.md, or the page itself with `Accept: text/markdown`; the home page at /index.md), is listed in |
| 9 | * /llms.txt, /llms-full.txt and the sitemap, and carries schema.org structured data: the organization, |
| 10 | * the product, breadcrumbs, the date its facts were last checked, and its questions and answers. |
| 11 | */ |
| 12 | import { raw } from "hono/html"; |
| 13 | import type { FC } from "hono/jsx"; |
| 14 | import type { AppContext } from "../auth/principal.js"; |
| 15 | import { SITE_NAV, SITE_PAGES } from "../docs/content.generated.js"; |
| 16 | import { imageProxy } from "../lib/camo.js"; |
| 17 | import { ICONS } from "./assets.js"; |
| 18 | import { fenceHighlighter } from "./highlight.js"; |
| 19 | import { escapeHtml, fenceLangs, renderMarkdown } from "./markdown.js"; |
| 20 | import { SITE } from "./seo.js"; |
| 21 | import { render, type WebApp } from "./ui.js"; |
| 22 | |
| 23 | export interface SitePageInfo { |
| 24 | /** URL path, e.g. "/compare/github". */ |
| 25 | path: string; |
| 26 | title: string; |
| 27 | description: string; |
| 28 | markdown: string; |
| 29 | /** The date its facts were last checked, YYYY-MM-DD. */ |
| 30 | updated: string; |
| 31 | /** Blog posts: the date the post first went out, YYYY-MM-DD (`updated` when it's left out). */ |
| 32 | published?: string; |
| 33 | seoTitle: string | null; |
| 34 | /** The site/nav.json section it belongs to ("Product", "Compare", "Company"). */ |
| 35 | section: string; |
| 36 | } |
| 37 | |
| 38 | const origin = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 39 | |
| 40 | /** Every site page, in the order of site/nav.json. */ |
| 41 | export function sitePages(): SitePageInfo[] { |
| 42 | return SITE_NAV.flatMap((s) => s.pages.map((path) => ({ path, ...SITE_PAGES[path]!, section: s.title }))); |
| 43 | } |
| 44 | |
| 45 | export function sitePage(path: string): SitePageInfo | null { |
| 46 | return sitePages().find((p) => p.path === path) ?? null; |
| 47 | } |
| 48 | |
| 49 | /** Root-relative links (`](/docs/...)`) made absolute, for Markdown read outside the site. */ |
| 50 | const absolute = (md: string, o: string) => md.replace(/\]\(\/(?!\/)/g, `](${o}/`); |
| 51 | |
| 52 | const longDate = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" }); |
| 53 | |
| 54 | /** A site page as standalone Markdown: what /<page>.md serves. */ |
| 55 | export function sitePageMarkdown(p: SitePageInfo, o: string): string { |
| 56 | return `# ${p.title}\n\n> ${p.description}\n\nSource: ${o}${p.path} (last updated ${p.updated})\n\n${absolute(p.markdown, o).trim()}\n`; |
| 57 | } |
| 58 | |
| 59 | // ---------------------------------------------------------------- questions and answers |
| 60 | |
| 61 | /** The "## Frequently asked questions" section of a page: each `###` heading and the text under it. */ |
| 62 | export function faqs(markdown: string): Array<{ q: string; a: string }> { |
| 63 | const m = /^## Frequently asked questions\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(markdown); |
| 64 | if (!m) return []; |
| 65 | return [...m[1]!.matchAll(/^### +(.+?)\s*$([\s\S]*?)(?=^### |(?![\s\S]))/gm)].map((x) => ({ q: x[1]!.trim(), a: x[2]!.trim() })).filter((x) => x.a); |
| 66 | } |
| 67 | |
| 68 | /** Markdown reduced to plain text, for structured data. */ |
| 69 | export const plainText = (md: string) => |
| 70 | md |
| 71 | .replace(/`([^`]*)`/g, "$1") |
| 72 | .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") |
| 73 | .replace(/\*\*([^*]+)\*\*/g, "$1") |
| 74 | .replace(/\s+/g, " ") |
| 75 | .trim(); |
| 76 | |
| 77 | // ---------------------------------------------------------------- the home page |
| 78 | |
| 79 | /** When the home page's facts were last checked. */ |
| 80 | export const HOME_UPDATED = "2026-10-25"; |
| 81 | |
| 82 | const HOME = { |
| 83 | // No SITE here: seo.tsx imports this module, so SITE isn't defined yet while it loads. |
| 84 | title: "Diffinery: open-source GitHub alternative with fast CI, built for AI agents", |
| 85 | description: |
| 86 | "Diffinery is open-source code hosting and CI for the agentic era, running on your own Cloudflare account: Git repositories, pull requests, GitHub Actions–compatible CI on 4-vCPU runners, and AI agents with their own accounts and guardrails.", |
| 87 | h1: "Code hosting and CI for the agentic era", |
| 88 | lead: "Diffinery is an open-source GitHub alternative for teams of people and AI agents. Git repositories, pull requests, GitHub Actions–compatible CI on fast runners, and agents with their own accounts and guardrails, running entirely on your own Cloudflare account.", |
| 89 | stats: [ |
| 90 | { value: "4 vCPU", label: "per CI job by default. GitHub's standard runner for private repositories has 2." }, |
| 91 | { value: "3–5 s", label: "from a job being queued to its first step." }, |
| 92 | { value: "Apache-2.0", label: "Open source, with nothing to buy. You run it on your own Cloudflare account." }, |
| 93 | { value: "1 person", label: "approves every agent pull request, enforced by the server." }, |
| 94 | ], |
| 95 | features: [ |
| 96 | { icon: "repo", title: "Git hosting", text: "Clone, fetch and push over HTTPS with Git LFS. Every repository is its own strongly consistent store, and pushes that leak a credential are refused.", href: "/features#git-hosting" }, |
| 97 | { icon: "pr", title: "Pull requests with intent", text: "Reviews, required approvals and checks, auto-merge and a merge queue. Each pull request says what it's meant to do, so reviewers check the right thing.", href: "/features#pull-requests-reviews-and-issues" }, |
| 98 | { icon: "play", title: "GitHub Actions–compatible CI", text: "The same triggers, expressions, matrices, reusable workflows, secrets, OIDC and Docker. Actions from GitHub run as they do there.", href: "/features#actions-ci-that-runs-your-github-workflows" }, |
| 99 | { icon: "zap", title: "Fast, right-sized runners", text: "4 vCPUs and 12 GiB per job by default, started in seconds. Jobs that only wait move to smaller runners on their own, never at the cost of speed.", href: "/performance" }, |
| 100 | { icon: "bot", title: "AI agents with guardrails", text: "Agents get their own accounts and tokens, an MCP server, sandboxes, tasks and reviews. They can't push to protected branches or reach your secrets.", href: "/agents" }, |
| 101 | { icon: "shield", title: "Secure by default", text: "Passkeys instead of passwords, invite-only sign-up, encrypted secrets, scoped job tokens and an append-only audit log.", href: "/security" }, |
| 102 | ], |
| 103 | workflow: `name: CI |
| 104 | on: [push, pull_request] |
| 105 | jobs: |
| 106 | test: |
| 107 | runs-on: ubuntu-latest # 4 vCPU, 12 GiB on Diffinery |
| 108 | steps: |
| 109 | - uses: actions/checkout@v4 |
| 110 | - uses: actions/setup-node@v4 |
| 111 | with: { node-version: 22, cache: pnpm } |
| 112 | - run: pnpm install --frozen-lockfile |
| 113 | - run: pnpm test`, |
| 114 | compare: { |
| 115 | cols: ["Diffinery", "GitHub", "GitHub with a runner service"], |
| 116 | rows: [ |
| 117 | ["Git hosting, pull requests, issues", "Yes", "Yes", "Yes, on GitHub"], |
| 118 | ["Runs GitHub Actions workflows", "Yes, as they are", "Yes", "Yes, with a new runs-on label"], |
| 119 | ["Default Linux runner, private repositories", "4 vCPU, 12 GiB", "2 vCPU, 8 GB", "Your choice of size, billed by the vendor"], |
| 120 | ["Guardrails for AI agents", "Built in for every agent account", "Rulesets and permissions you configure", "Rulesets and permissions you configure"], |
| 121 | ["Built-in MCP server and agent sandboxes", "Yes", "MCP server, Codespaces", "GitHub's"], |
| 122 | ["Hosted macOS and Windows runners", "No (self-hosted)", "Yes", "Depends on the vendor"], |
| 123 | ["Open source", "Yes, Apache-2.0", "No", "No"], |
| 124 | ["Who runs it", "You, on your own Cloudflare account", "GitHub", "GitHub and the runner vendor"], |
| 125 | ], |
| 126 | }, |
| 127 | faq: [ |
| 128 | { |
| 129 | q: "What is Diffinery?", |
| 130 | a: "Diffinery is a git forge and CI platform for teams of people and AI agents. It hosts Git repositories, pull requests, issues and releases, runs GitHub Actions workflows on its own hosted runners, and gives AI agents their own accounts, tokens and server-enforced guardrails. It runs entirely on Cloudflare, and it's open source under the Apache License 2.0: you deploy it to your own Cloudflare account.", |
| 131 | }, |
| 132 | { |
| 133 | q: "Is Diffinery an alternative to GitHub?", |
| 134 | a: "Yes, for teams whose work fits it: Diffinery replaces GitHub's code hosting, pull requests and GitHub Actions in one platform. It doesn't have GitHub's hosted macOS and Windows runners, forks, app marketplace, Codespaces, Pages or Discussions. See [Diffinery vs GitHub](/compare/github).", |
| 135 | }, |
| 136 | { |
| 137 | q: "Do my GitHub Actions workflows run on Diffinery?", |
| 138 | a: "Most run without changes. Diffinery reads `.github/workflows/*.yml` with the same syntax, triggers, expressions and actions. Hosted runners are Linux x64; macOS, Windows and Arm jobs need a self-hosted runner. Every difference is listed in [Compatibility with GitHub Actions](/docs/actions/compatibility).", |
| 139 | }, |
| 140 | { |
| 141 | q: "Is Diffinery built on Cloudflare?", |
| 142 | a: "Yes, entirely: Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare. One Cloudflare Worker serves the site, the API, Git and the MCP server; each repository is its own Durable Object, with large objects in R2 and accounts in D1; Workflows and Queues drive CI runs, webhooks and email; and every CI job runs in a Cloudflare Container that exists only while the job runs. There are no servers to manage. See [Built on Cloudflare](/built-on-cloudflare).", |
| 143 | }, |
| 144 | { |
| 145 | q: "How is Diffinery different from Blacksmith or Depot?", |
| 146 | a: "Blacksmith sells faster runners for GitHub Actions, and Depot sells runners, its own CI engine and container builds. With either, your code and pull requests usually stay on GitHub and jobs run on their machines. Diffinery replaces GitHub and GitHub Actions together, with fast runners built in, as one open-source platform you run on your own Cloudflare account. See [Diffinery vs Blacksmith](/compare/blacksmith) and [Diffinery vs Depot](/compare/depot).", |
| 147 | }, |
| 148 | { |
| 149 | q: "How does Diffinery work with Claude Code, Codex and other AI agents?", |
| 150 | a: "Agents connect over MCP at `/mcp` on your instance with a browser sign-in, or use Git, the GitHub CLI and the REST API with an agent token. Each agent is an account of its own: it can't push to protected branches, its pull requests need a person's approval, and runs of its code get no secrets. See [Diffinery for AI agents](/agents).", |
| 151 | }, |
| 152 | { |
| 153 | q: "How do I move from GitHub?", |
| 154 | a: "Diffinery's importer copies a repository or a whole organization from GitHub on the server, with history, issues, pull requests, reviews, releases and branch protection, keeping their numbers. Then you enter your secrets' values and push. See [Migrate from GitHub](/docs/migrate-from-github).", |
| 155 | }, |
| 156 | { |
| 157 | q: "Is Diffinery open source?", |
| 158 | a: "Yes, under the Apache License 2.0, with no paid edition and nothing sold. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). See [Open source](/open-source).", |
| 159 | }, |
| 160 | { |
| 161 | q: "How do I start using Diffinery, and what does it cost?", |
| 162 | a: "Deploy your own instance to your Cloudflare account and domain with one command, `scripts/setup.sh`, then sign in as its first administrator and invite your team. See [Deploy your own Diffinery](/docs/self-hosting/deploy). Diffinery is free; you pay Cloudflare for what your instance uses, on the Workers Paid plan (a minimum of $5 USD per month per account, plus usage; [Cloudflare's pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). There's no hosted service to sign up for: diffinery.com is the project's own instance.", |
| 163 | }, |
| 164 | ], |
| 165 | }; |
| 166 | |
| 167 | /** The home page as Markdown, for agents: /index.md, and / with `Accept: text/markdown`. */ |
| 168 | export function homeMarkdown(o: string): string { |
| 169 | const lines = [ |
| 170 | `# ${SITE.name}`, |
| 171 | "", |
| 172 | `> ${HOME.description}`, |
| 173 | "", |
| 174 | `Source: ${o}/ (last updated ${HOME_UPDATED})`, |
| 175 | "", |
| 176 | `## ${HOME.h1}`, |
| 177 | "", |
| 178 | HOME.lead, |
| 179 | "", |
| 180 | ...HOME.stats.map((s) => `- **${s.value}**: ${s.label}`), |
| 181 | "", |
| 182 | "## What you get", |
| 183 | "", |
| 184 | ...HOME.features.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`), |
| 185 | "", |
| 186 | "## Your workflows, as they are", |
| 187 | "", |
| 188 | "```yaml", |
| 189 | HOME.workflow, |
| 190 | "```", |
| 191 | "", |
| 192 | "## How Diffinery compares", |
| 193 | "", |
| 194 | `| | ${HOME.compare.cols.join(" | ")} |`, |
| 195 | `| --- | ${HOME.compare.cols.map(() => "---").join(" | ")} |`, |
| 196 | ...HOME.compare.rows.map((r) => `| ${r.join(" | ")} |`), |
| 197 | "", |
| 198 | "Runner services include Blacksmith and Depot. See [all comparisons](/compare).", |
| 199 | "", |
| 200 | "## Open source, on your own Cloudflare account", |
| 201 | "", |
| 202 | `Diffinery is open source under the Apache License 2.0: [source code](${SOURCE.repo}), [mirror on GitHub](${SOURCE.github}). Deploy it with [Deploy your own Diffinery](/docs/self-hosting/deploy); you pay Cloudflare only for what your instance uses. See [Open source](/open-source).`, |
| 203 | "", |
| 204 | "## Frequently asked questions", |
| 205 | "", |
| 206 | ...HOME.faq.flatMap((f) => [`### ${f.q}`, "", f.a, ""]), |
| 207 | ]; |
| 208 | return absolute(lines.join("\n"), o); |
| 209 | } |
| 210 | |
| 211 | /** Everything the home page says, for IndexNow (web/indexnow.ts): its Markdown and the landing page's own copy. */ |
| 212 | export async function homeContent(o: string): Promise<string> { |
| 213 | return `${homeMarkdown(o)}\n${await (<Landing />).toString()}`; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * What sets Diffinery apart on Cloudflare, in the words every page uses (llms.txt, structured data). Checked against |
| 218 | * the other Git and CI projects on Cloudflare listed on /built-on-cloudflare; re-check them when this changes. |
| 219 | */ |
| 220 | export const CLOUDFLARE_CLAIM = |
| 221 | "an open-source platform where Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare (checked October 25, 2026)."; |
| 222 | |
| 223 | /** Where Diffinery's source lives, whatever instance serves this page, and its license. */ |
| 224 | export const SOURCE = { |
| 225 | repo: "https://diffinery.com/codician/diffinery", |
| 226 | github: "https://github.com/codician-team/diffinery", |
| 227 | license: "https://www.apache.org/licenses/LICENSE-2.0", |
| 228 | spdx: "Apache-2.0", |
| 229 | }; |
| 230 | |
| 231 | /** schema.org ids for the site's entities, shared by every page's graph. */ |
| 232 | const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` }); |
| 233 | |
| 234 | function entities(o: string): object[] { |
| 235 | const id = ids(o); |
| 236 | return [ |
| 237 | { "@type": "Organization", "@id": id.org, name: SITE.name, url: `${o}/`, logo: { "@type": "ImageObject", url: `${o}/icon-512.png`, width: 512, height: 512 }, description: SITE.description }, |
| 238 | { "@type": "WebSite", "@id": id.site, name: SITE.name, url: `${o}/`, inLanguage: "en", publisher: { "@id": id.org } }, |
| 239 | { |
| 240 | "@type": "SoftwareApplication", |
| 241 | "@id": id.app, |
| 242 | name: SITE.name, |
| 243 | url: `${o}/`, |
| 244 | applicationCategory: "DeveloperApplication", |
| 245 | applicationSubCategory: "Git hosting and continuous integration", |
| 246 | operatingSystem: "Web", |
| 247 | description: HOME.lead, |
| 248 | featureList: [...HOME.features.map((f) => `${f.title}: ${f.text}`), `Runs entirely on Cloudflare: ${CLOUDFLARE_CLAIM}`, `Open source under the Apache License 2.0, deployed to your own Cloudflare account`], |
| 249 | license: SOURCE.license, |
| 250 | isAccessibleForFree: true, |
| 251 | offers: { "@type": "Offer", price: "0", priceCurrency: "USD", description: "Free, open-source software. Your instance's Cloudflare usage is billed by Cloudflare." }, |
| 252 | softwareHelp: { "@type": "CreativeWork", url: `${o}/docs/self-hosting/deploy` }, |
| 253 | installUrl: `${o}/docs/self-hosting/deploy`, |
| 254 | downloadUrl: `${SOURCE.repo}/releases`, |
| 255 | publisher: { "@id": id.org }, |
| 256 | sameAs: [`${o}/docs`, SOURCE.repo, SOURCE.github], |
| 257 | }, |
| 258 | { |
| 259 | "@type": "SoftwareSourceCode", |
| 260 | "@id": id.source, |
| 261 | name: SITE.name, |
| 262 | description: `The source code of ${SITE.name}, which you deploy to your own Cloudflare account.`, |
| 263 | codeRepository: SOURCE.repo, |
| 264 | sameAs: [SOURCE.github], |
| 265 | license: SOURCE.license, |
| 266 | programmingLanguage: "TypeScript", |
| 267 | runtimePlatform: "Cloudflare Workers", |
| 268 | isAccessibleForFree: true, |
| 269 | targetProduct: { "@id": id.app }, |
| 270 | publisher: { "@id": id.org }, |
| 271 | }, |
| 272 | ]; |
| 273 | } |
| 274 | |
| 275 | function faqPage(url: string, list: Array<{ q: string; a: string }>): object[] { |
| 276 | if (!list.length) return []; |
| 277 | return [{ "@type": "FAQPage", "@id": `${url}#faq`, url, mainEntity: list.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plainText(f.a) } })) }]; |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Structured data for a docs page (docs/web.tsx): a TechArticle in the site's graph, with breadcrumbs (Home, Docs, the |
| 282 | * page), the date it was last reviewed when its frontmatter says, and its questions and answers. |
| 283 | */ |
| 284 | export function docsJsonLd(o: string, p: { url: string; title: string; description: string; section: string; markdown: string; updated?: string; home?: boolean }): object[] { |
| 285 | const id = ids(o); |
| 286 | const trail = [ |
| 287 | { name: "Home", item: `${o}/` }, |
| 288 | { name: "Docs", item: `${o}/docs` }, |
| 289 | ...(p.home ? [] : [{ name: p.title, item: p.url }]), |
| 290 | ]; |
| 291 | return [ |
| 292 | ...entities(o), |
| 293 | { |
| 294 | "@type": "TechArticle", |
| 295 | "@id": `${p.url}#page`, |
| 296 | url: p.url, |
| 297 | name: p.title, |
| 298 | headline: p.title, |
| 299 | description: p.description, |
| 300 | articleSection: p.section, |
| 301 | inLanguage: "en", |
| 302 | ...(p.updated ? { dateModified: p.updated } : {}), |
| 303 | author: { "@id": id.org }, |
| 304 | publisher: { "@id": id.org }, |
| 305 | isPartOf: { "@id": id.site }, |
| 306 | about: { "@id": id.app }, |
| 307 | breadcrumb: { "@id": `${p.url}#breadcrumb` }, |
| 308 | }, |
| 309 | { "@type": "BreadcrumbList", "@id": `${p.url}#breadcrumb`, itemListElement: trail.map((t, i) => ({ "@type": "ListItem", position: i + 1, name: t.name, item: t.item })) }, |
| 310 | ...faqPage(p.url, faqs(p.markdown)), |
| 311 | ]; |
| 312 | } |
| 313 | |
| 314 | const Cta: FC = () => ( |
| 315 | <> |
| 316 | <a class="btn primary" href="/docs/self-hosting"> |
| 317 | Deploy your own |
| 318 | </a> |
| 319 | <a class="btn" href={SOURCE.repo}> |
| 320 | View the source |
| 321 | </a> |
| 322 | <a class="btn" href="/docs"> |
| 323 | Read the docs |
| 324 | </a> |
| 325 | </> |
| 326 | ); |
| 327 | |
| 328 | /** Inline Markdown (links and code) of a home page answer, as HTML. */ |
| 329 | const inline = (md: string) => renderMarkdown(md, { idPrefix: "" }).replace(/^<p>|<\/p>\s*$/g, ""); |
| 330 | |
| 331 | /** The home page for visitors. */ |
| 332 | export const Landing: FC = () => ( |
| 333 | <div class="mk"> |
| 334 | <style>{raw(MARKETING_CSS)}</style> |
| 335 | <section class="hero"> |
| 336 | <a class="ea-eyebrow" href="/open-source"> |
| 337 | {raw(ICONS.code)} Open source under Apache-2.0 |
| 338 | </a> |
| 339 | <h1>{HOME.h1}</h1> |
| 340 | <p>{HOME.lead}</p> |
| 341 | <p class="row" style="justify-content:center"> |
| 342 | <Cta /> |
| 343 | </p> |
| 344 | <p class="muted small"> |
| 345 | Free, with nothing to buy: you run Diffinery on your own Cloudflare account. Source on <a href={SOURCE.repo}>Diffinery</a> and <a href={SOURCE.github}>GitHub</a>. |
| 346 | </p> |
| 347 | </section> |
| 348 | <ul class="mk-stats" aria-label="Diffinery in numbers"> |
| 349 | {HOME.stats.map((s) => ( |
| 350 | <li> |
| 351 | <b>{s.value}</b> |
| 352 | <span>{s.label}</span> |
| 353 | </li> |
| 354 | ))} |
| 355 | </ul> |
| 356 | <section class="mk-sec"> |
| 357 | <h2>Everything your team and its agents need, in one place</h2> |
| 358 | <div class="features"> |
| 359 | {HOME.features.map((f) => ( |
| 360 | <a class="box mk-card" href={f.href}> |
| 361 | <h3> |
| 362 | {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title} |
| 363 | </h3> |
| 364 | <p class="muted m0">{f.text}</p> |
| 365 | </a> |
| 366 | ))} |
| 367 | </div> |
| 368 | </section> |
| 369 | <section class="mk-sec mk-split"> |
| 370 | <div> |
| 371 | <h2>Your workflows, as they are</h2> |
| 372 | <p>Diffinery reads <code>.github/workflows</code> with the same triggers, expressions, matrices, reusable workflows, environments, secrets and OIDC as GitHub Actions. Common actions such as checkout, setup-node and cache are built into the runner, so they finish in seconds.</p> |
| 373 | <ul> |
| 374 | <li>Each job gets a fresh Ubuntu 24.04 container with 4 vCPUs and 12 GiB of memory.</li> |
| 375 | <li>Caches restore in parallel and extract while they download.</li> |
| 376 | <li>Self-hosted runners cover macOS, Windows, Arm and GPUs.</li> |
| 377 | </ul> |
| 378 | <p> |
| 379 | <a href="/performance">See the measurements</a> · <a href="/docs/actions/compatibility">Compatibility with GitHub Actions</a> |
| 380 | </p> |
| 381 | </div> |
| 382 | {/* One line: whitespace inside <pre> shows. */} |
| 383 | {raw(`<pre class="mk-code" aria-label="A GitHub Actions workflow that runs on Diffinery unchanged"><code>${escapeHtml(HOME.workflow)}</code></pre>`)} |
| 384 | </section> |
| 385 | <section class="mk-sec"> |
| 386 | <h2>Built for AI coding agents</h2> |
| 387 | <p class="mk-wide">Claude Code, Codex and other agents work through Git, the GitHub CLI, the REST API or the built-in MCP server, as accounts of their own. The server keeps a person in charge of what lands.</p> |
| 388 | <div class="features"> |
| 389 | <div class="box mk-card"> |
| 390 | <h3>{raw(ICONS.bot)} Agent accounts</h3> |
| 391 | <p class="muted m0">Their own logins and tokens, owned by a person or organization, with every commit stamped with who they act for.</p> |
| 392 | </div> |
| 393 | <div class="box mk-card"> |
| 394 | <h3>{raw(ICONS.shield)} Guardrails</h3> |
| 395 | <p class="muted m0">No pushes to protected branches, a person's approval on every agent pull request, and no secrets for agent-written code.</p> |
| 396 | </div> |
| 397 | <div class="box mk-card"> |
| 398 | <h3>{raw(ICONS.code)} MCP, sandboxes and tasks</h3> |
| 399 | <p class="muted m0">One URL connects any MCP app. Agents get sandboxes to build and test in, and tasks turn a goal into a pull request.</p> |
| 400 | </div> |
| 401 | </div> |
| 402 | <p> |
| 403 | <a href="/agents">Diffinery for AI agents</a> · <a href="/docs/connect">Connect Claude, Claude Code or Codex</a> |
| 404 | </p> |
| 405 | </section> |
| 406 | <section class="mk-sec"> |
| 407 | <h2>How Diffinery compares</h2> |
| 408 | <div class="table-wrap"> |
| 409 | <table class="mk-table"> |
| 410 | <thead> |
| 411 | <tr> |
| 412 | <th></th> |
| 413 | {HOME.compare.cols.map((h) => ( |
| 414 | <th scope="col">{h}</th> |
| 415 | ))} |
| 416 | </tr> |
| 417 | </thead> |
| 418 | <tbody> |
| 419 | {HOME.compare.rows.map((r) => ( |
| 420 | <tr> |
| 421 | <th scope="row">{r[0]}</th> |
| 422 | {r.slice(1).map((v) => ( |
| 423 | <td>{v}</td> |
| 424 | ))} |
| 425 | </tr> |
| 426 | ))} |
| 427 | </tbody> |
| 428 | </table> |
| 429 | </div> |
| 430 | <p class="muted small"> |
| 431 | Runner services include Blacksmith and Depot. GitHub's runner sizes are from its documentation, checked {longDate(HOME_UPDATED)}. Read the full comparisons with{" "} |
| 432 | <a href="/compare/github">GitHub</a>, <a href="/compare/gitlab">GitLab</a>, <a href="/compare/blacksmith">Blacksmith</a> and <a href="/compare/depot">Depot</a>. |
| 433 | </p> |
| 434 | </section> |
| 435 | <section class="mk-sec mk-faq" id="faq"> |
| 436 | <h2>Frequently asked questions</h2> |
| 437 | {HOME.faq.map((f) => ( |
| 438 | <div class="mk-q"> |
| 439 | <h3>{f.q}</h3> |
| 440 | <p>{raw(inline(f.a))}</p> |
| 441 | </div> |
| 442 | ))} |
| 443 | </section> |
| 444 | <section class="mk-band"> |
| 445 | <h2>Run Diffinery on your own Cloudflare account</h2> |
| 446 | <p class="muted">Open source under Apache-2.0. Your workflows, your editor and gh keep working, your agents get guardrails, and you pay Cloudflare only for what you use.</p> |
| 447 | <p class="row" style="justify-content:center"> |
| 448 | <Cta /> |
| 449 | </p> |
| 450 | </section> |
| 451 | </div> |
| 452 | ); |
| 453 | |
| 454 | /** The home page for signed-out visitors (registerAccount calls this for "/"). */ |
| 455 | export function landingPage(c: AppContext): Response | Promise<Response> { |
| 456 | const o = origin(c); |
| 457 | if (wantsMarkdown(c)) return markdownResponse(c, homeMarkdown(o)); |
| 458 | c.header("link", `<${o}/index.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`); |
| 459 | c.header("vary", "Accept"); |
| 460 | return render( |
| 461 | c, |
| 462 | { |
| 463 | title: "", |
| 464 | home: true, |
| 465 | seoTitle: HOME.title, |
| 466 | description: HOME.description, |
| 467 | 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)], |
| 468 | head: <link rel="alternate" type="text/markdown" href="/index.md" title="Diffinery (Markdown)" />, |
| 469 | }, |
| 470 | <Landing />, |
| 471 | ); |
| 472 | } |
| 473 | |
| 474 | // ---------------------------------------------------------------- the Markdown pages (site/) |
| 475 | |
| 476 | const wantsMarkdown = (c: AppContext) => /\btext\/markdown\b/i.test(c.req.header("accept") ?? ""); |
| 477 | const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" }; |
| 478 | const markdownResponse = (c: AppContext, body: string) => c.body(body, 200, { ...PUBLIC, "content-type": "text/markdown; charset=utf-8", vary: "Accept" }); |
| 479 | |
| 480 | const htmlCache = new Map<string, string>(); |
| 481 | async function pageHtml(c: AppContext, p: SitePageInfo): Promise<string> { |
| 482 | let html = htmlCache.get(p.path); |
| 483 | if (html === undefined) { |
| 484 | html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)), image: imageProxy(c.env) }); |
| 485 | htmlCache.set(p.path, html); |
| 486 | } |
| 487 | return html; |
| 488 | } |
| 489 | |
| 490 | /** Blog posts: /blog/<post>, not the /blog index. */ |
| 491 | const isPost = (p: SitePageInfo) => p.path.startsWith("/blog/"); |
| 492 | |
| 493 | /** Comparisons, measurements and blog posts are articles: dated, with sources. */ |
| 494 | const isArticle = (p: SitePageInfo) => p.path.startsWith("/compare/") || p.path === "/performance" || p.path === "/built-on-cloudflare" || isPost(p); |
| 495 | |
| 496 | function crumbs(p: SitePageInfo): Array<{ name: string; path: string }> { |
| 497 | const out = [{ name: "Home", path: "/" }]; |
| 498 | if (p.path.startsWith("/compare/")) out.push({ name: "Compare", path: "/compare" }); |
| 499 | if (isPost(p)) out.push({ name: "Blog", path: "/blog" }); |
| 500 | out.push({ name: p.title, path: p.path }); |
| 501 | return out; |
| 502 | } |
| 503 | |
| 504 | async function sitePageResponse(c: AppContext, p: SitePageInfo): Promise<Response> { |
| 505 | const o = origin(c); |
| 506 | const url = `${o}${p.path}`; |
| 507 | const html = await pageHtml(c, p); |
| 508 | const id = ids(o); |
| 509 | const trail = crumbs(p); |
| 510 | const related = sitePages().filter((x) => x.section === p.section && x.path !== p.path); |
| 511 | c.header("link", `<${o}${p.path}.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`); |
| 512 | c.header("vary", "Accept"); |
| 513 | const page = { |
| 514 | "@type": isPost(p) ? "BlogPosting" : isArticle(p) ? "Article" : p.path === "/about" ? "AboutPage" : "WebPage", |
| 515 | "@id": `${url}#page`, |
| 516 | url, |
| 517 | name: p.title, |
| 518 | ...(isArticle(p) ? { headline: p.title } : {}), |
| 519 | description: p.description, |
| 520 | inLanguage: "en", |
| 521 | ...(isPost(p) ? { datePublished: p.published ?? p.updated } : {}), |
| 522 | dateModified: p.updated, |
| 523 | ...(isArticle(p) ? { author: { "@id": id.org } } : {}), |
| 524 | publisher: { "@id": id.org }, |
| 525 | isPartOf: { "@id": id.site }, |
| 526 | about: { "@id": id.app }, |
| 527 | breadcrumb: { "@id": `${url}#breadcrumb` }, |
| 528 | }; |
| 529 | const breadcrumb = { "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, itemListElement: trail.map((t, i) => ({ "@type": "ListItem", position: i + 1, name: t.name, item: `${o}${t.path}` })) }; |
| 530 | return render( |
| 531 | c, |
| 532 | { |
| 533 | title: p.title, |
| 534 | seoTitle: p.seoTitle ?? undefined, |
| 535 | description: p.description, |
| 536 | modified: isArticle(p) ? p.updated : undefined, |
| 537 | jsonLd: [...entities(o), page, breadcrumb, ...faqPage(url, faqs(p.markdown))], |
| 538 | head: <link rel="alternate" type="text/markdown" href={`${p.path}.md`} title={`${p.title} (Markdown)`} />, |
| 539 | }, |
| 540 | <article class="mk mk-page"> |
| 541 | <style>{raw(MARKETING_CSS)}</style> |
| 542 | <header class="mk-head"> |
| 543 | <nav class="mk-crumbs" aria-label="Breadcrumb"> |
| 544 | {trail.slice(0, -1).map((t) => ( |
| 545 | <> |
| 546 | <a href={t.path}>{t.name}</a> <span class="faint">/</span>{" "} |
| 547 | </> |
| 548 | ))} |
| 549 | </nav> |
| 550 | <h1>{p.title}</h1> |
| 551 | <p class="mk-lead">{p.description}</p> |
| 552 | <p class="mk-meta muted small"> |
| 553 | {isPost(p) && p.published && p.published !== p.updated ? ( |
| 554 | <> |
| 555 | Published <time datetime={p.published}>{longDate(p.published)}</time> ·{" "} |
| 556 | </> |
| 557 | ) : null} |
| 558 | Last updated <time datetime={p.updated}>{longDate(p.updated)}</time> ·{" "} |
| 559 | <a href={`${p.path}.md`} title="This page as plain Markdown"> |
| 560 | View as Markdown |
| 561 | </a> |
| 562 | </p> |
| 563 | </header> |
| 564 | <div class="markdown mk-body">{raw(html)}</div> |
| 565 | <aside class="mk-band"> |
| 566 | <h2>Run Diffinery yourself</h2> |
| 567 | <p class="muted">Open source under Apache-2.0, deployed to your own Cloudflare account.</p> |
| 568 | <p class="row" style="justify-content:center"> |
| 569 | <Cta /> |
| 570 | </p> |
| 571 | </aside> |
| 572 | {related.length ? ( |
| 573 | <nav class="mk-related" aria-label={`More in ${p.section}`}> |
| 574 | <h2>More in {p.section}</h2> |
| 575 | <ul> |
| 576 | {related.map((r) => ( |
| 577 | <li> |
| 578 | <a href={r.path}>{r.title}</a> |
| 579 | <span class="muted small">{r.description}</span> |
| 580 | </li> |
| 581 | ))} |
| 582 | </ul> |
| 583 | </nav> |
| 584 | ) : null} |
| 585 | </article>, |
| 586 | ); |
| 587 | } |
| 588 | |
| 589 | /** The sitemap's entries for the site: the home page first, then every page with the date it was last checked. */ |
| 590 | export function siteSitemapEntries(): Array<{ path: string; updated: string }> { |
| 591 | return [{ path: "/", updated: HOME_UPDATED }, ...sitePages().map((p) => ({ path: p.path, updated: p.updated }))]; |
| 592 | } |
| 593 | |
| 594 | /** The site's part of /llms.txt: what Diffinery is, then every site page as Markdown. */ |
| 595 | export function siteLlmsLines(o: string): string[] { |
| 596 | const lines = [ |
| 597 | "## About Diffinery", |
| 598 | "", |
| 599 | `- [Home page](${o}/index.md): ${HOME.description}`, |
| 600 | `- Open source under the Apache License 2.0 (SPDX: ${SOURCE.spdx}). Source code: ${SOURCE.repo}, mirrored to ${SOURCE.github}. Free, with no paid edition and nothing sold.`, |
| 601 | `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for other teams: diffinery.com is the project's home and its own instance, with accounts by invitation for the people working on Diffinery.`, |
| 602 | `- Runs entirely on Cloudflare (Workers, Durable Objects, R2, D1, Queues, Workflows, Containers): code, pull requests, CI runners and the agent features alike. It is ${CLOUDFLARE_CLAIM} See ${o}/built-on-cloudflare.md, which compares the other Git and CI projects on Cloudflare.`, |
| 603 | "- Hosted CI runners: Ubuntu 24.04 x64 containers, 4 vCPU and 12 GiB of memory by default, with smaller sizes chosen per job from its measured runs. No hosted macOS, Windows or Arm runners; self-hosted runners cover them.", |
| 604 | "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`, |
| 605 | "", |
| 606 | ]; |
| 607 | for (const s of SITE_NAV) { |
| 608 | lines.push(`## ${s.title}`, ""); |
| 609 | for (const path of s.pages) { |
| 610 | const p = sitePage(path)!; |
| 611 | lines.push(`- [${p.title}](${o}${path}.md): ${p.description} (updated ${p.updated})`); |
| 612 | } |
| 613 | lines.push(""); |
| 614 | } |
| 615 | return lines; |
| 616 | } |
| 617 | |
| 618 | /** The site's pages for /llms-full.txt, home page first. */ |
| 619 | export function siteFullMarkdown(o: string): string[] { |
| 620 | return [homeMarkdown(o), ...sitePages().map((p) => sitePageMarkdown(p, o))]; |
| 621 | } |
| 622 | |
| 623 | export function registerSite(web: WebApp): void { |
| 624 | web.get("/index.md", (c) => markdownResponse(c, homeMarkdown(origin(c)))); |
| 625 | for (const p of sitePages()) { |
| 626 | web.get(`${p.path}.md`, (c) => markdownResponse(c, sitePageMarkdown(p, origin(c)))); |
| 627 | web.get(`${p.path}/`, (c) => c.redirect(p.path, 301)); |
| 628 | web.get(p.path, (c) => (wantsMarkdown(c) ? markdownResponse(c, sitePageMarkdown(p, origin(c))) : sitePageResponse(c, p))); |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | export const MARKETING_CSS = ` |
| 633 | .mk .hero h1{max-width:820px;margin-left:auto;margin-right:auto;font-size:48px;line-height:1.08} |
| 634 | .mk .hero>p:first-of-type{max-width:720px} |
| 635 | .mk-stats{list-style:none;margin:8px 0 8px;padding:0;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} |
| 636 | .mk-stats li{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);padding:16px} |
| 637 | .mk-stats b{display:block;font-size:24px;letter-spacing:-.02em;margin-bottom:4px} |
| 638 | .mk-stats span{color:var(--fg2);font-size:13.5px;line-height:1.45} |
| 639 | .mk-sec{margin:56px 0 0} |
| 640 | .mk-sec>h2,.mk-band h2,.mk-related h2{font-size:26px;letter-spacing:-.02em;margin:0 0 10px} |
| 641 | .mk-wide{max-width:760px;color:var(--fg2);font-size:16px} |
| 642 | .mk .features{grid-template-columns:repeat(3,minmax(0,1fr))} |
| 643 | .mk-card{display:block;color:var(--fg)}a.mk-card:hover{border-color:var(--accent);text-decoration:none} |
| 644 | .mk-card h3{display:flex;align-items:center;gap:8px}.mk-card h3 svg{flex:none;color:var(--accent)} |
| 645 | .mk-split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;align-items:center} |
| 646 | .mk-split>*{min-width:0} |
| 647 | .mk .table-wrap{overflow:auto} |
| 648 | .mk-split p,.mk-split li{color:var(--fg2);font-size:15px;line-height:1.65} |
| 649 | .mk-code{margin:0;padding:18px 20px;background:var(--code-bg);border:1px solid var(--border);border-radius:var(--r);overflow:auto;font-size:13px;line-height:1.6} |
| 650 | .mk-table{width:100%;border-collapse:collapse;font-size:14px} |
| 651 | .mk-table th,.mk-table td{border-bottom:1px solid var(--border2);padding:10px 12px;text-align:left;vertical-align:top} |
| 652 | .mk-table thead th{font-weight:600;border-bottom:1px solid var(--border)}.mk-table thead th:nth-child(2){color:var(--accent)} |
| 653 | .mk-table tbody th{font-weight:500;color:var(--fg2)} |
| 654 | .mk-faq .mk-q{border-top:1px solid var(--border2);padding:14px 0}.mk-faq h3{font-size:16px;margin:0 0 6px}.mk-faq p{margin:0;color:var(--fg2);line-height:1.65;max-width:860px} |
| 655 | .mk-band{margin:56px 0 0;padding:36px 20px;text-align:center;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2)} |
| 656 | .mk-band p{margin:0 auto 14px;max-width:620px} |
| 657 | .mk-page{max-width:880px;margin:0 auto} |
| 658 | .mk-head{padding:28px 0 18px;border-bottom:1px solid var(--border2);margin-bottom:8px} |
| 659 | .mk-head h1{font-size:36px;line-height:1.15;letter-spacing:-.025em;margin:8px 0 10px} |
| 660 | .mk-crumbs{font-size:13px;min-height:18px} |
| 661 | .mk-lead{font-size:18px;color:var(--fg2);margin:0 0 10px;line-height:1.55} |
| 662 | .mk-meta{margin:0} |
| 663 | .mk-body{font-size:15.5px;line-height:1.7} |
| 664 | .mk-body h2{font-size:23px;margin-top:38px;scroll-margin-top:64px}.mk-body h3{font-size:17px;margin-top:26px;scroll-margin-top:64px} |
| 665 | .mk-body table{font-size:14px;width:100%}.mk-body td code{white-space:nowrap} |
| 666 | .mk-related{margin:40px 0 0} |
| 667 | .mk-related ul{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px} |
| 668 | .mk-related li{border:1px solid var(--border);border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column;gap:4px} |
| 669 | @media (max-width:900px){.mk .features{grid-template-columns:repeat(2,minmax(0,1fr))}.mk-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.mk-split{grid-template-columns:minmax(0,1fr)}} |
| 670 | @media (max-width:600px){.mk .features{grid-template-columns:minmax(0,1fr)}.mk .hero{padding-top:40px}.mk .hero h1{font-size:32px}.mk-head h1{font-size:27px}.mk-stats{grid-template-columns:minmax(0,1fr)}.mk-sec>h2,.mk-band h2{font-size:22px}} |
| 671 | `; |