| 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, run tasks with Claude Code or Codex, review pull requests and merge on green. 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 | /** What agents do on Diffinery: the home page's "Built for AI coding agents" cards, its Markdown and the structured data. */ |
| 104 | agents: [ |
| 105 | { icon: "shield", title: "Agent accounts with guardrails", text: "Each agent has its own login and tokens, owned by a person or an organization. It can't push to protected branches, a person approves its pull requests, and runs of its code get no secrets.", href: "/docs/agents/overview#guardrails" }, |
| 106 | { icon: "bot", title: "Agent tasks", text: "Give an agent a goal: Claude Code or Codex works in a sandbox with your own model key and opens a pull request. Plan first, best of N attempts, and fixes for its own failing checks.", href: "/docs/agents/tasks" }, |
| 107 | { icon: "eye", title: "Agent review", text: "An agent reviews every pull request for bugs, security issues and logic errors, posts inline findings with suggested fixes and reports an Agent review check.", href: "/docs/agents/review" }, |
| 108 | { icon: "history", title: "Today and Needs you", text: "One page each morning: what needs you, what your agents are working on, and what merged or failed since yesterday, across every repository.", href: "/docs/agents/today" }, |
| 109 | { icon: "prMerged", title: "Merge on green", text: "Pull requests merge by themselves once their checks pass, with or without a person's approval, as you choose. An agent's change to a workflow file still needs a person to merge it.", href: "/docs/collaboration/merge-on-green" }, |
| 110 | { icon: "key", title: "Your own subscription", text: "Open a terminal in the browser, sign in to Claude Code or Codex with your own Claude or ChatGPT plan, and give it tasks that open pull requests.", href: "/docs/agents/terminal" }, |
| 111 | ], |
| 112 | /** The tools Diffinery plugs into: the home page's integrations, its Markdown and the structured data. */ |
| 113 | integrations: [ |
| 114 | { icon: "bot", title: "Claude Code plugin", text: "The MCP server, a skill for the branch → pull request → CI → merge loop, /diffinery:pr-status, and a live band of the pull request's CI checks above the prompt.", install: "claude plugin marketplace add codician-team/diffinery", href: "/docs/connect/claude-code" }, |
| 115 | { icon: "code", title: "Codex plugin", text: "The same MCP server, skills and session hook for OpenAI's Codex CLI.", install: "codex plugin marketplace add https://diffinery.com/codician/diffinery.git", href: "/docs/connect/codex" }, |
| 116 | { icon: "link", title: "MCP server, in the official registry", text: "52 tools for code, pull requests, CI logs, sandboxes, Today and the docs, with a browser sign-in, for Claude, Claude Code, Codex, Cursor and any MCP client. Listed as com.diffinery/diffinery.", install: "https://<your host>/mcp", href: "/docs/agents/mcp" }, |
| 117 | { icon: "play", title: "The diffinery CLI and gh", text: "Pull requests with intent, auto-merge, runs and logs, runners and migrations. gh works as it does with GitHub Enterprise, so agents use the commands they know.", install: "curl -fsSL https://diffinery.com/install | bash", href: "/docs/agents/github-cli" }, |
| 118 | { icon: "repo", title: "VS Code, Cursor and Windsurf", text: "Clone from Diffinery with a passkey sign-in, and push and pull with it.", install: "https://diffinery.com/install-editor", href: "/docs/repositories/clone-in-your-editor" }, |
| 119 | { icon: "package", title: "JetBrains IDEs", text: "Get from Version Control with a passkey sign-in, in IntelliJ IDEA, PyCharm, WebStorm and the rest (2024.3 or later).", install: "https://diffinery.com/-/jetbrains/updatePlugins.xml", href: "/docs/repositories/clone-in-jetbrains-ides" }, |
| 120 | ], |
| 121 | workflow: `name: CI |
| 122 | on: [push, pull_request] |
| 123 | jobs: |
| 124 | test: |
| 125 | runs-on: ubuntu-latest # 4 vCPU, 12 GiB on Diffinery |
| 126 | steps: |
| 127 | - uses: actions/checkout@v4 |
| 128 | - uses: actions/setup-node@v4 |
| 129 | with: { node-version: 22, cache: pnpm } |
| 130 | - run: pnpm install --frozen-lockfile |
| 131 | - run: pnpm test`, |
| 132 | compare: { |
| 133 | cols: ["Diffinery", "GitHub", "GitHub with a runner service"], |
| 134 | rows: [ |
| 135 | ["Git hosting, pull requests, issues", "Yes", "Yes", "Yes, on GitHub"], |
| 136 | ["Runs GitHub Actions workflows", "Yes, as they are", "Yes", "Yes, with a new runs-on label"], |
| 137 | ["Default Linux runner, private repositories", "4 vCPU, 12 GiB", "2 vCPU, 8 GB", "Your choice of size, billed by the vendor"], |
| 138 | ["Guardrails for AI agents", "Built in for every agent account", "Rulesets and permissions you configure", "Rulesets and permissions you configure"], |
| 139 | ["Built-in MCP server and agent sandboxes", "Yes", "MCP server, Codespaces", "GitHub's"], |
| 140 | ["Hosted macOS and Windows runners", "No (self-hosted)", "Yes", "Depends on the vendor"], |
| 141 | ["Open source", "Yes, Apache-2.0", "No", "No"], |
| 142 | ["Who runs it", "You, on your own Cloudflare account", "GitHub", "GitHub and the runner vendor"], |
| 143 | ], |
| 144 | }, |
| 145 | faq: [ |
| 146 | { |
| 147 | q: "What is Diffinery?", |
| 148 | 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.", |
| 149 | }, |
| 150 | { |
| 151 | q: "Is Diffinery an alternative to GitHub?", |
| 152 | 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).", |
| 153 | }, |
| 154 | { |
| 155 | q: "Do my GitHub Actions workflows run on Diffinery?", |
| 156 | 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).", |
| 157 | }, |
| 158 | { |
| 159 | q: "Is Diffinery built on Cloudflare?", |
| 160 | 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).", |
| 161 | }, |
| 162 | { |
| 163 | q: "How is Diffinery different from Blacksmith or Depot?", |
| 164 | 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).", |
| 165 | }, |
| 166 | { |
| 167 | q: "How does Diffinery work with Claude Code, Codex and other AI agents?", |
| 168 | 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).", |
| 169 | }, |
| 170 | { |
| 171 | q: "How do I move from GitHub?", |
| 172 | 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).", |
| 173 | }, |
| 174 | { |
| 175 | q: "Is Diffinery open source?", |
| 176 | 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).", |
| 177 | }, |
| 178 | { |
| 179 | q: "Can I try Diffinery without deploying it?", |
| 180 | a: "Yes. [Request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance: its administrators review requests and email invitations, and you try Diffinery there. To run Diffinery for your team, deploy your own instance. See [Early access](/docs/accounts/early-access).", |
| 181 | }, |
| 182 | { |
| 183 | q: "How do I start using Diffinery, and what does it cost?", |
| 184 | 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; to try Diffinery first, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance.", |
| 185 | }, |
| 186 | ], |
| 187 | }; |
| 188 | |
| 189 | /** The home page as Markdown, for agents: /index.md, and / with `Accept: text/markdown`. */ |
| 190 | export function homeMarkdown(o: string): string { |
| 191 | const lines = [ |
| 192 | `# ${SITE.name}`, |
| 193 | "", |
| 194 | `> ${HOME.description}`, |
| 195 | "", |
| 196 | `Source: ${o}/ (last updated ${HOME_UPDATED})`, |
| 197 | "", |
| 198 | `## ${HOME.h1}`, |
| 199 | "", |
| 200 | HOME.lead, |
| 201 | "", |
| 202 | ...HOME.stats.map((s) => `- **${s.value}**: ${s.label}`), |
| 203 | "", |
| 204 | "## What you get", |
| 205 | "", |
| 206 | ...HOME.features.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`), |
| 207 | "", |
| 208 | "## Built for AI coding agents", |
| 209 | "", |
| 210 | AGENTS_LEAD, |
| 211 | "", |
| 212 | ...HOME.agents.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`), |
| 213 | "", |
| 214 | "## Works where you and your agents already work", |
| 215 | "", |
| 216 | ...HOME.integrations.map((f) => `- **[${f.title}](${f.href})**: ${f.text} \`${f.install}\``), |
| 217 | "", |
| 218 | "## Your workflows, as they are", |
| 219 | "", |
| 220 | "```yaml", |
| 221 | HOME.workflow, |
| 222 | "```", |
| 223 | "", |
| 224 | "## How Diffinery compares", |
| 225 | "", |
| 226 | `| | ${HOME.compare.cols.join(" | ")} |`, |
| 227 | `| --- | ${HOME.compare.cols.map(() => "---").join(" | ")} |`, |
| 228 | ...HOME.compare.rows.map((r) => `| ${r.join(" | ")} |`), |
| 229 | "", |
| 230 | "Runner services include Blacksmith and Depot. See [all comparisons](/compare).", |
| 231 | "", |
| 232 | "## Open source, on your own Cloudflare account", |
| 233 | "", |
| 234 | `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. To try it first, [request early access](${EARLY_ACCESS}) to diffinery.com. See [Open source](/open-source).`, |
| 235 | "", |
| 236 | "## Frequently asked questions", |
| 237 | "", |
| 238 | ...HOME.faq.flatMap((f) => [`### ${f.q}`, "", f.a, ""]), |
| 239 | ]; |
| 240 | return absolute(lines.join("\n"), o); |
| 241 | } |
| 242 | |
| 243 | const AGENTS_LEAD = |
| 244 | "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, and Diffinery can run them for you. Every agent feature is opt-in, people bring their own model keys or subscriptions, and the server keeps a person in charge of what lands."; |
| 245 | |
| 246 | /** Everything the home page says, for IndexNow (web/indexnow.ts): its Markdown and the landing page's own copy. */ |
| 247 | export async function homeContent(o: string): Promise<string> { |
| 248 | return `${homeMarkdown(o)}\n${await (<Landing />).toString()}`; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * What sets Diffinery apart on Cloudflare, in the words every page uses (llms.txt, structured data). Checked against |
| 253 | * the other Git and CI projects on Cloudflare listed on /built-on-cloudflare; re-check them when this changes. |
| 254 | */ |
| 255 | export const CLOUDFLARE_CLAIM = |
| 256 | "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)."; |
| 257 | |
| 258 | /** Where Diffinery's source lives, whatever instance serves this page, and its license. */ |
| 259 | export const SOURCE = { |
| 260 | repo: "https://diffinery.com/codician/diffinery", |
| 261 | github: "https://github.com/codician-team/diffinery", |
| 262 | license: "https://www.apache.org/licenses/LICENSE-2.0", |
| 263 | spdx: "Apache-2.0", |
| 264 | }; |
| 265 | |
| 266 | /** Where to ask to try Diffinery on the project's own instance, whatever instance serves this page (web/early-access.tsx). */ |
| 267 | export const EARLY_ACCESS = "https://diffinery.com/early-access"; |
| 268 | |
| 269 | /** schema.org ids for the site's entities, shared by every page's graph. */ |
| 270 | const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` }); |
| 271 | |
| 272 | function entities(o: string): object[] { |
| 273 | const id = ids(o); |
| 274 | return [ |
| 275 | { "@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 }, |
| 276 | { "@type": "WebSite", "@id": id.site, name: SITE.name, url: `${o}/`, inLanguage: "en", publisher: { "@id": id.org } }, |
| 277 | { |
| 278 | "@type": "SoftwareApplication", |
| 279 | "@id": id.app, |
| 280 | name: SITE.name, |
| 281 | url: `${o}/`, |
| 282 | applicationCategory: "DeveloperApplication", |
| 283 | applicationSubCategory: "Git hosting and continuous integration", |
| 284 | operatingSystem: "Web", |
| 285 | description: HOME.lead, |
| 286 | featureList: [ |
| 287 | ...HOME.features.map((f) => `${f.title}: ${f.text}`), |
| 288 | ...HOME.agents.map((f) => `${f.title}: ${f.text}`), |
| 289 | ...HOME.integrations.map((f) => `${f.title}: ${f.text}`), |
| 290 | `Runs entirely on Cloudflare: ${CLOUDFLARE_CLAIM}`, `Open source under the Apache License 2.0, deployed to your own Cloudflare account`, |
| 291 | ], |
| 292 | license: SOURCE.license, |
| 293 | isAccessibleForFree: true, |
| 294 | offers: { "@type": "Offer", price: "0", priceCurrency: "USD", description: "Free, open-source software. Your instance's Cloudflare usage is billed by Cloudflare." }, |
| 295 | softwareHelp: { "@type": "CreativeWork", url: `${o}/docs/self-hosting/deploy` }, |
| 296 | installUrl: `${o}/docs/self-hosting/deploy`, |
| 297 | downloadUrl: `${SOURCE.repo}/releases`, |
| 298 | publisher: { "@id": id.org }, |
| 299 | sameAs: [`${o}/docs`, SOURCE.repo, SOURCE.github], |
| 300 | }, |
| 301 | { |
| 302 | "@type": "SoftwareSourceCode", |
| 303 | "@id": id.source, |
| 304 | name: SITE.name, |
| 305 | description: `The source code of ${SITE.name}, which you deploy to your own Cloudflare account.`, |
| 306 | codeRepository: SOURCE.repo, |
| 307 | sameAs: [SOURCE.github], |
| 308 | license: SOURCE.license, |
| 309 | programmingLanguage: "TypeScript", |
| 310 | runtimePlatform: "Cloudflare Workers", |
| 311 | isAccessibleForFree: true, |
| 312 | targetProduct: { "@id": id.app }, |
| 313 | publisher: { "@id": id.org }, |
| 314 | }, |
| 315 | ]; |
| 316 | } |
| 317 | |
| 318 | function faqPage(url: string, list: Array<{ q: string; a: string }>): object[] { |
| 319 | if (!list.length) return []; |
| 320 | return [{ "@type": "FAQPage", "@id": `${url}#faq`, url, mainEntity: list.map((f) => ({ "@type": "Question", name: f.q, acceptedAnswer: { "@type": "Answer", text: plainText(f.a) } })) }]; |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Structured data for a docs page (docs/web.tsx): a TechArticle in the site's graph, with breadcrumbs (Home, Docs, the |
| 325 | * page), the date it was last reviewed when its frontmatter says, and its questions and answers. |
| 326 | */ |
| 327 | export function docsJsonLd(o: string, p: { url: string; title: string; description: string; section: string; markdown: string; updated?: string; home?: boolean }): object[] { |
| 328 | const id = ids(o); |
| 329 | const trail = [ |
| 330 | { name: "Home", item: `${o}/` }, |
| 331 | { name: "Docs", item: `${o}/docs` }, |
| 332 | ...(p.home ? [] : [{ name: p.title, item: p.url }]), |
| 333 | ]; |
| 334 | return [ |
| 335 | ...entities(o), |
| 336 | { |
| 337 | "@type": "TechArticle", |
| 338 | "@id": `${p.url}#page`, |
| 339 | url: p.url, |
| 340 | name: p.title, |
| 341 | headline: p.title, |
| 342 | description: p.description, |
| 343 | articleSection: p.section, |
| 344 | inLanguage: "en", |
| 345 | ...(p.updated ? { dateModified: p.updated } : {}), |
| 346 | author: { "@id": id.org }, |
| 347 | publisher: { "@id": id.org }, |
| 348 | isPartOf: { "@id": id.site }, |
| 349 | about: { "@id": id.app }, |
| 350 | breadcrumb: { "@id": `${p.url}#breadcrumb` }, |
| 351 | }, |
| 352 | { "@type": "BreadcrumbList", "@id": `${p.url}#breadcrumb`, itemListElement: trail.map((t, i) => ({ "@type": "ListItem", position: i + 1, name: t.name, item: t.item })) }, |
| 353 | ...faqPage(p.url, faqs(p.markdown)), |
| 354 | ]; |
| 355 | } |
| 356 | |
| 357 | const Cta: FC = () => ( |
| 358 | <> |
| 359 | <a class="btn primary" href="/docs/self-hosting"> |
| 360 | Deploy your own |
| 361 | </a> |
| 362 | <a class="btn" href={EARLY_ACCESS} title="Try Diffinery on diffinery.com before you deploy your own"> |
| 363 | Request early access |
| 364 | </a> |
| 365 | <a class="btn" href={SOURCE.repo}> |
| 366 | View the source |
| 367 | </a> |
| 368 | </> |
| 369 | ); |
| 370 | |
| 371 | /** Inline Markdown (links and code) of a home page answer, as HTML. */ |
| 372 | const inline = (md: string) => renderMarkdown(md, { idPrefix: "" }).replace(/^<p>|<\/p>\s*$/g, ""); |
| 373 | |
| 374 | /** The home page for visitors. */ |
| 375 | export const Landing: FC = () => ( |
| 376 | <div class="mk"> |
| 377 | <style>{raw(MARKETING_CSS)}</style> |
| 378 | <section class="hero"> |
| 379 | <a class="ea-eyebrow" href="/open-source"> |
| 380 | {raw(ICONS.code)} Open source under Apache-2.0 |
| 381 | </a> |
| 382 | <h1>{HOME.h1}</h1> |
| 383 | <p>{HOME.lead}</p> |
| 384 | <p class="row" style="justify-content:center"> |
| 385 | <Cta /> |
| 386 | </p> |
| 387 | <p class="muted small"> |
| 388 | 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>. |
| 389 | </p> |
| 390 | </section> |
| 391 | <ul class="mk-stats" aria-label="Diffinery in numbers"> |
| 392 | {HOME.stats.map((s) => ( |
| 393 | <li> |
| 394 | <b>{s.value}</b> |
| 395 | <span>{s.label}</span> |
| 396 | </li> |
| 397 | ))} |
| 398 | </ul> |
| 399 | <section class="mk-sec"> |
| 400 | <h2>Everything your team and its agents need, in one place</h2> |
| 401 | <div class="features"> |
| 402 | {HOME.features.map((f) => ( |
| 403 | <a class="box mk-card" href={f.href}> |
| 404 | <h3> |
| 405 | {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title} |
| 406 | </h3> |
| 407 | <p class="muted m0">{f.text}</p> |
| 408 | </a> |
| 409 | ))} |
| 410 | </div> |
| 411 | </section> |
| 412 | <section class="mk-sec mk-split"> |
| 413 | <div> |
| 414 | <h2>Your workflows, as they are</h2> |
| 415 | <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> |
| 416 | <ul> |
| 417 | <li>Each job gets a fresh Ubuntu 24.04 container with 4 vCPUs and 12 GiB of memory.</li> |
| 418 | <li>Caches restore in parallel and extract while they download.</li> |
| 419 | <li>Self-hosted runners cover macOS, Windows, Arm and GPUs.</li> |
| 420 | </ul> |
| 421 | <p> |
| 422 | <a href="/performance">See the measurements</a> · <a href="/docs/actions/compatibility">Compatibility with GitHub Actions</a> |
| 423 | </p> |
| 424 | </div> |
| 425 | {/* One line: whitespace inside <pre> shows. */} |
| 426 | {raw(`<pre class="mk-code" aria-label="A GitHub Actions workflow that runs on Diffinery unchanged"><code>${escapeHtml(HOME.workflow)}</code></pre>`)} |
| 427 | </section> |
| 428 | <section class="mk-sec" id="agents"> |
| 429 | <h2>Built for AI coding agents</h2> |
| 430 | <p class="mk-wide">{AGENTS_LEAD}</p> |
| 431 | <div class="features"> |
| 432 | {HOME.agents.map((f) => ( |
| 433 | <a class="box mk-card" href={f.href}> |
| 434 | <h3> |
| 435 | {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title} |
| 436 | </h3> |
| 437 | <p class="muted m0">{f.text}</p> |
| 438 | </a> |
| 439 | ))} |
| 440 | </div> |
| 441 | <figure class="mk-shot"> |
| 442 | <a href="/docs/agents/today"> |
| 443 | <img src="/docs/images/today.webp" width="1280" height="1000" loading="lazy" alt="The Today page on Diffinery: what needs you, the agent pull requests at work, your agent tasks and what changed since yesterday" /> |
| 444 | </a> |
| 445 | <figcaption class="muted small">Today, the first page after you sign in: an agent pull request waiting for a person, the agents at work and the task that opened it.</figcaption> |
| 446 | </figure> |
| 447 | <p> |
| 448 | <a href="/agents">Diffinery for AI agents</a> · <a href="/docs/connect">Connect Claude, Claude Code or Codex</a> |
| 449 | </p> |
| 450 | </section> |
| 451 | <section class="mk-sec" id="integrations"> |
| 452 | <h2>Works where you and your agents already work</h2> |
| 453 | <p class="mk-wide">Plugins and extensions for the tools you use, all with a browser sign-in, for diffinery.com or your own instance.</p> |
| 454 | <div class="features"> |
| 455 | {HOME.integrations.map((f) => ( |
| 456 | <a class="box mk-card" href={f.href}> |
| 457 | <h3> |
| 458 | {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title} |
| 459 | </h3> |
| 460 | <p class="muted">{f.text}</p> |
| 461 | <code class="mk-install">{f.install}</code> |
| 462 | </a> |
| 463 | ))} |
| 464 | </div> |
| 465 | <p> |
| 466 | <a href="/docs/connect">Connect AI apps</a> · <a href="/features#works-with-the-tools-you-already-use">Every integration</a> |
| 467 | </p> |
| 468 | </section> |
| 469 | <section class="mk-sec"> |
| 470 | <h2>How Diffinery compares</h2> |
| 471 | <div class="table-wrap"> |
| 472 | <table class="mk-table"> |
| 473 | <thead> |
| 474 | <tr> |
| 475 | <th></th> |
| 476 | {HOME.compare.cols.map((h) => ( |
| 477 | <th scope="col">{h}</th> |
| 478 | ))} |
| 479 | </tr> |
| 480 | </thead> |
| 481 | <tbody> |
| 482 | {HOME.compare.rows.map((r) => ( |
| 483 | <tr> |
| 484 | <th scope="row">{r[0]}</th> |
| 485 | {r.slice(1).map((v) => ( |
| 486 | <td>{v}</td> |
| 487 | ))} |
| 488 | </tr> |
| 489 | ))} |
| 490 | </tbody> |
| 491 | </table> |
| 492 | </div> |
| 493 | <p class="muted small"> |
| 494 | Runner services include Blacksmith and Depot. GitHub's runner sizes are from its documentation, checked {longDate(HOME_UPDATED)}. Read the full comparisons with{" "} |
| 495 | <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>. |
| 496 | </p> |
| 497 | </section> |
| 498 | <section class="mk-sec mk-faq" id="faq"> |
| 499 | <h2>Frequently asked questions</h2> |
| 500 | {HOME.faq.map((f) => ( |
| 501 | <div class="mk-q"> |
| 502 | <h3>{f.q}</h3> |
| 503 | <p>{raw(inline(f.a))}</p> |
| 504 | </div> |
| 505 | ))} |
| 506 | </section> |
| 507 | <section class="mk-band"> |
| 508 | <h2>Run Diffinery on your own Cloudflare account</h2> |
| 509 | <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. Not ready to deploy? Request early access and try it on diffinery.com first.</p> |
| 510 | <p class="row" style="justify-content:center"> |
| 511 | <Cta /> |
| 512 | </p> |
| 513 | </section> |
| 514 | </div> |
| 515 | ); |
| 516 | |
| 517 | /** The home page for signed-out visitors (registerAccount calls this for "/"). */ |
| 518 | export function landingPage(c: AppContext): Response | Promise<Response> { |
| 519 | const o = origin(c); |
| 520 | if (wantsMarkdown(c)) return markdownResponse(c, homeMarkdown(o)); |
| 521 | c.header("link", `<${o}/index.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`); |
| 522 | c.header("vary", "Accept"); |
| 523 | return render( |
| 524 | c, |
| 525 | { |
| 526 | title: "", |
| 527 | home: true, |
| 528 | seoTitle: HOME.title, |
| 529 | description: HOME.description, |
| 530 | 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 | head: <link rel="alternate" type="text/markdown" href="/index.md" title="Diffinery (Markdown)" />, |
| 532 | }, |
| 533 | <Landing />, |
| 534 | ); |
| 535 | } |
| 536 | |
| 537 | // ---------------------------------------------------------------- the Markdown pages (site/) |
| 538 | |
| 539 | const wantsMarkdown = (c: AppContext) => /\btext\/markdown\b/i.test(c.req.header("accept") ?? ""); |
| 540 | const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" }; |
| 541 | const markdownResponse = (c: AppContext, body: string) => c.body(body, 200, { ...PUBLIC, "content-type": "text/markdown; charset=utf-8", vary: "Accept" }); |
| 542 | |
| 543 | const htmlCache = new Map<string, string>(); |
| 544 | async function pageHtml(c: AppContext, p: SitePageInfo): Promise<string> { |
| 545 | let html = htmlCache.get(p.path); |
| 546 | if (html === undefined) { |
| 547 | html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)), image: imageProxy(c.env) }); |
| 548 | htmlCache.set(p.path, html); |
| 549 | } |
| 550 | return html; |
| 551 | } |
| 552 | |
| 553 | /** Blog posts: /blog/<post>, not the /blog index. */ |
| 554 | const isPost = (p: SitePageInfo) => p.path.startsWith("/blog/"); |
| 555 | |
| 556 | /** Comparisons, measurements and blog posts are articles: dated, with sources. */ |
| 557 | const isArticle = (p: SitePageInfo) => p.path.startsWith("/compare/") || p.path === "/performance" || p.path === "/built-on-cloudflare" || isPost(p); |
| 558 | |
| 559 | function crumbs(p: SitePageInfo): Array<{ name: string; path: string }> { |
| 560 | const out = [{ name: "Home", path: "/" }]; |
| 561 | if (p.path.startsWith("/compare/")) out.push({ name: "Compare", path: "/compare" }); |
| 562 | if (isPost(p)) out.push({ name: "Blog", path: "/blog" }); |
| 563 | out.push({ name: p.title, path: p.path }); |
| 564 | return out; |
| 565 | } |
| 566 | |
| 567 | async function sitePageResponse(c: AppContext, p: SitePageInfo): Promise<Response> { |
| 568 | const o = origin(c); |
| 569 | const url = `${o}${p.path}`; |
| 570 | const html = await pageHtml(c, p); |
| 571 | const id = ids(o); |
| 572 | const trail = crumbs(p); |
| 573 | const related = sitePages().filter((x) => x.section === p.section && x.path !== p.path); |
| 574 | c.header("link", `<${o}${p.path}.md>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`); |
| 575 | c.header("vary", "Accept"); |
| 576 | const page = { |
| 577 | "@type": isPost(p) ? "BlogPosting" : isArticle(p) ? "Article" : p.path === "/about" ? "AboutPage" : "WebPage", |
| 578 | "@id": `${url}#page`, |
| 579 | url, |
| 580 | name: p.title, |
| 581 | ...(isArticle(p) ? { headline: p.title } : {}), |
| 582 | description: p.description, |
| 583 | inLanguage: "en", |
| 584 | ...(isPost(p) ? { datePublished: p.published ?? p.updated } : {}), |
| 585 | dateModified: p.updated, |
| 586 | ...(isArticle(p) ? { author: { "@id": id.org } } : {}), |
| 587 | publisher: { "@id": id.org }, |
| 588 | isPartOf: { "@id": id.site }, |
| 589 | about: { "@id": id.app }, |
| 590 | breadcrumb: { "@id": `${url}#breadcrumb` }, |
| 591 | }; |
| 592 | const breadcrumb = { "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, itemListElement: trail.map((t, i) => ({ "@type": "ListItem", position: i + 1, name: t.name, item: `${o}${t.path}` })) }; |
| 593 | return render( |
| 594 | c, |
| 595 | { |
| 596 | title: p.title, |
| 597 | seoTitle: p.seoTitle ?? undefined, |
| 598 | description: p.description, |
| 599 | modified: isArticle(p) ? p.updated : undefined, |
| 600 | jsonLd: [...entities(o), page, breadcrumb, ...faqPage(url, faqs(p.markdown))], |
| 601 | head: <link rel="alternate" type="text/markdown" href={`${p.path}.md`} title={`${p.title} (Markdown)`} />, |
| 602 | }, |
| 603 | <article class="mk mk-page"> |
| 604 | <style>{raw(MARKETING_CSS)}</style> |
| 605 | <header class="mk-head"> |
| 606 | <nav class="mk-crumbs" aria-label="Breadcrumb"> |
| 607 | {trail.slice(0, -1).map((t) => ( |
| 608 | <> |
| 609 | <a href={t.path}>{t.name}</a> <span class="faint">/</span>{" "} |
| 610 | </> |
| 611 | ))} |
| 612 | </nav> |
| 613 | <h1>{p.title}</h1> |
| 614 | <p class="mk-lead">{p.description}</p> |
| 615 | <p class="mk-meta muted small"> |
| 616 | {isPost(p) && p.published && p.published !== p.updated ? ( |
| 617 | <> |
| 618 | Published <time datetime={p.published}>{longDate(p.published)}</time> ·{" "} |
| 619 | </> |
| 620 | ) : null} |
| 621 | Last updated <time datetime={p.updated}>{longDate(p.updated)}</time> ·{" "} |
| 622 | <a href={`${p.path}.md`} title="This page as plain Markdown"> |
| 623 | View as Markdown |
| 624 | </a> |
| 625 | </p> |
| 626 | </header> |
| 627 | <div class="markdown mk-body">{raw(html)}</div> |
| 628 | <aside class="mk-band"> |
| 629 | <h2>Run Diffinery yourself</h2> |
| 630 | <p class="muted">Open source under Apache-2.0, deployed to your own Cloudflare account. Or request early access and try it on diffinery.com first.</p> |
| 631 | <p class="row" style="justify-content:center"> |
| 632 | <Cta /> |
| 633 | </p> |
| 634 | </aside> |
| 635 | {related.length ? ( |
| 636 | <nav class="mk-related" aria-label={`More in ${p.section}`}> |
| 637 | <h2>More in {p.section}</h2> |
| 638 | <ul> |
| 639 | {related.map((r) => ( |
| 640 | <li> |
| 641 | <a href={r.path}>{r.title}</a> |
| 642 | <span class="muted small">{r.description}</span> |
| 643 | </li> |
| 644 | ))} |
| 645 | </ul> |
| 646 | </nav> |
| 647 | ) : null} |
| 648 | </article>, |
| 649 | ); |
| 650 | } |
| 651 | |
| 652 | /** The sitemap's entries for the site: the home page first, then every page with the date it was last checked. */ |
| 653 | export function siteSitemapEntries(): Array<{ path: string; updated: string }> { |
| 654 | return [{ path: "/", updated: HOME_UPDATED }, ...sitePages().map((p) => ({ path: p.path, updated: p.updated }))]; |
| 655 | } |
| 656 | |
| 657 | /** The site's part of /llms.txt: what Diffinery is, then every site page as Markdown. */ |
| 658 | export function siteLlmsLines(o: string): string[] { |
| 659 | const lines = [ |
| 660 | "## About Diffinery", |
| 661 | "", |
| 662 | `- [Home page](${o}/index.md): ${HOME.description}`, |
| 663 | `- 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.`, |
| 664 | `- 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 teams to sign up for: diffinery.com is the project's home and its own instance, with accounts by invitation. To try Diffinery before deploying your own, request early access at ${EARLY_ACCESS} (how it works: ${o}/docs/accounts/early-access.md).`, |
| 665 | `- 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.`, |
| 666 | "- 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.", |
| 667 | "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`, |
| 668 | `- Built for AI coding agents (${o}/agents.md): agent accounts with server-enforced guardrails (${o}/docs/agents/overview.md); agent tasks that run Claude Code or Codex in a sandbox with the person's own model key and open a pull request (${o}/docs/agents/tasks.md); agent review with inline findings and an Agent review check (${o}/docs/agents/review.md); a Today page of what needs you (${o}/docs/agents/today.md); merge on green (${o}/docs/collaboration/merge-on-green.md); browser terminals where Claude Code or Codex runs on the person's own Claude or ChatGPT plan (${o}/docs/agents/terminal.md). All opt-in; Diffinery doesn't sell or include model access.`, |
| 669 | `- Plugins and integrations: Claude Code and Codex plugins (marketplace codician-team/diffinery on GitHub, or ${SOURCE.repo}.git), an MCP server listed in the official MCP Registry as com.diffinery/diffinery (https://{diffinery_host}/mcp), the diffinery CLI, gh through the GitHub-compatible REST and GraphQL APIs, and extensions for VS Code, Cursor, Windsurf and JetBrains IDEs. Guides: ${o}/docs/connect.md`, |
| 670 | "", |
| 671 | ]; |
| 672 | for (const s of SITE_NAV) { |
| 673 | lines.push(`## ${s.title}`, ""); |
| 674 | for (const path of s.pages) { |
| 675 | const p = sitePage(path)!; |
| 676 | lines.push(`- [${p.title}](${o}${path}.md): ${p.description} (updated ${p.updated})`); |
| 677 | } |
| 678 | lines.push(""); |
| 679 | } |
| 680 | return lines; |
| 681 | } |
| 682 | |
| 683 | /** The site's pages for /llms-full.txt, home page first. */ |
| 684 | export function siteFullMarkdown(o: string): string[] { |
| 685 | return [homeMarkdown(o), ...sitePages().map((p) => sitePageMarkdown(p, o))]; |
| 686 | } |
| 687 | |
| 688 | export function registerSite(web: WebApp): void { |
| 689 | web.get("/index.md", (c) => markdownResponse(c, homeMarkdown(origin(c)))); |
| 690 | for (const p of sitePages()) { |
| 691 | web.get(`${p.path}.md`, (c) => markdownResponse(c, sitePageMarkdown(p, origin(c)))); |
| 692 | web.get(`${p.path}/`, (c) => c.redirect(p.path, 301)); |
| 693 | web.get(p.path, (c) => (wantsMarkdown(c) ? markdownResponse(c, sitePageMarkdown(p, origin(c))) : sitePageResponse(c, p))); |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | export const MARKETING_CSS = ` |
| 698 | .mk .hero h1{max-width:820px;margin-left:auto;margin-right:auto;font-size:48px;line-height:1.08} |
| 699 | .mk .hero>p:first-of-type{max-width:720px} |
| 700 | .mk-stats{list-style:none;margin:8px 0 8px;padding:0;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} |
| 701 | .mk-stats li{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);padding:16px} |
| 702 | .mk-stats b{display:block;font-size:24px;letter-spacing:-.02em;margin-bottom:4px} |
| 703 | .mk-stats span{color:var(--fg2);font-size:13.5px;line-height:1.45} |
| 704 | .mk-sec{margin:56px 0 0} |
| 705 | .mk-sec>h2,.mk-band h2,.mk-related h2{font-size:26px;letter-spacing:-.02em;margin:0 0 10px} |
| 706 | .mk-wide{max-width:760px;color:var(--fg2);font-size:16px} |
| 707 | .mk .features{grid-template-columns:repeat(3,minmax(0,1fr))} |
| 708 | .mk-card{display:block;color:var(--fg)}a.mk-card:hover{border-color:var(--accent);text-decoration:none} |
| 709 | .mk-card h3{display:flex;align-items:center;gap:8px}.mk-card h3 svg{flex:none;color:var(--accent)} |
| 710 | .mk-split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;align-items:center} |
| 711 | .mk-split>*{min-width:0} |
| 712 | .mk .table-wrap{overflow:auto} |
| 713 | .mk-split p,.mk-split li{color:var(--fg2);font-size:15px;line-height:1.65} |
| 714 | .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} |
| 715 | .mk-table{width:100%;border-collapse:collapse;font-size:14px} |
| 716 | .mk-table th,.mk-table td{border-bottom:1px solid var(--border2);padding:10px 12px;text-align:left;vertical-align:top} |
| 717 | .mk-table thead th{font-weight:600;border-bottom:1px solid var(--border)}.mk-table thead th:nth-child(2){color:var(--accent)} |
| 718 | .mk-table tbody th{font-weight:500;color:var(--fg2)} |
| 719 | .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} |
| 720 | .mk-install{display:block;font-size:12px;overflow-wrap:anywhere;margin-top:auto} |
| 721 | .mk-card:has(.mk-install){display:flex;flex-direction:column} |
| 722 | .mk-shot{margin:20px 0 12px}.mk-shot img{display:block;width:100%;height:auto;border:1px solid var(--border);border-radius:var(--r)}.mk-shot figcaption{margin-top:8px} |
| 723 | .mk-band{margin:56px 0 0;padding:36px 20px;text-align:center;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2)} |
| 724 | .mk-band p{margin:0 auto 14px;max-width:620px} |
| 725 | .mk-page{max-width:880px;margin:0 auto} |
| 726 | .mk-head{padding:28px 0 18px;border-bottom:1px solid var(--border2);margin-bottom:8px} |
| 727 | .mk-head h1{font-size:36px;line-height:1.15;letter-spacing:-.025em;margin:8px 0 10px} |
| 728 | .mk-crumbs{font-size:13px;min-height:18px} |
| 729 | .mk-lead{font-size:18px;color:var(--fg2);margin:0 0 10px;line-height:1.55} |
| 730 | .mk-meta{margin:0} |
| 731 | .mk-body{font-size:15.5px;line-height:1.7} |
| 732 | .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} |
| 733 | .mk-body table{font-size:14px;width:100%}.mk-body td code{white-space:nowrap} |
| 734 | .mk-related{margin:40px 0 0} |
| 735 | .mk-related ul{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px} |
| 736 | .mk-related li{border:1px solid var(--border);border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column;gap:4px} |
| 737 | @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)}} |
| 738 | @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}} |
| 739 | `; |