Diffinery

codician/diffinerypublic

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

169 lines · 8.6 KBRaw History
1/** @jsxImportSource hono/jsx */
2/**
3 * Favicons, app icons, the social preview image, robots.txt, sitemap.xml and the
4 * search/social meta tags every page carries.
5 *
6 * The PNG and ICO files come from scripts/make-icons.py (icons.generated.ts). Google
7 * shows a site's favicon in results when it is crawlable, square and a multiple of
8 * 48px, so /favicon.ico (16–64px) and /favicon-48.png are linked from every page.
9 */
10import { raw } from "hono/html";
11import type { AppContext } from "../auth/principal.js";
12import type { WebApp } from "./ui.js";
13import { ICON_FILES } from "./icons.generated.js";
14import { docsSitemapEntries } from "../docs/web.js";
15import { siteSitemapEntries } from "./site.js";
16import { indexNowKey } from "./indexnow.js";
17
18export const SITE = {
19 name: "Diffinery",
20 tagline: "Code hosting and CI for the agentic era",
21 description:
22 "Diffinery is open-source code hosting and CI for the agentic era: Git repositories, pull requests with intent, GitHub Actions compatible workflows and AI agents as first-class collaborators, running on your own Cloudflare account.",
23 color: "#f6821f",
24};
25
26/** The mark on an orange tile, for browsers that take SVG favicons. */
27export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="5.3" fill="${SITE.color}"/><g transform="translate(2.4 2.4) scale(.8)" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></g></svg>`;
28
29const TYPES: Record<string, string> = { ico: "image/x-icon", png: "image/png" };
30const bytes = new Map<string, Uint8Array<ArrayBuffer>>();
31function file(name: string): Uint8Array<ArrayBuffer> | null {
32 const b64 = ICON_FILES[name];
33 if (!b64) return null;
34 let b = bytes.get(name);
35 if (!b) {
36 const bin = atob(b64);
37 b = new Uint8Array(bin.length);
38 for (let i = 0; i < bin.length; i++) b[i] = bin.charCodeAt(i);
39 bytes.set(name, b);
40 }
41 return b;
42}
43
44const CACHE = "public, max-age=604800";
45
46/** Paths that are never useful in search results. */
47const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|-)(\/|$)/;
48
49export function registerSeo(web: WebApp): void {
50 for (const name of Object.keys(ICON_FILES)) {
51 const type = TYPES[name.split(".").pop()!]!;
52 web.get(`/${name}`, (c) => c.body(file(name)!, 200, { "content-type": type, "cache-control": CACHE, "x-content-type-options": "nosniff" }));
53 }
54 web.get("/favicon.svg", (c) => c.body(FAVICON_SVG, 200, { "content-type": "image/svg+xml", "cache-control": CACHE, "x-content-type-options": "nosniff" }));
55 web.get("/site.webmanifest", (c) =>
56 c.body(
57 JSON.stringify({
58 name: SITE.name,
59 short_name: SITE.name,
60 description: SITE.description,
61 start_url: "/",
62 display: "standalone",
63 background_color: "#ffffff",
64 theme_color: SITE.color,
65 icons: [
66 { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
67 { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
68 { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
69 ],
70 }),
71 200,
72 { "content-type": "application/manifest+json", "cache-control": CACHE },
73 ),
74 );
75 web.get("/robots.txt", (c) => {
76 const origin = c.env.PUBLIC_URL.replace(/\/$/, "");
77 const lines = [
78 // Search engines and AI assistants are welcome on every public page: the site, the docs, llms.txt
79 // and public repositories. Content-Signal (contentsignals.org) says so explicitly, for crawlers that read it.
80 "User-agent: *",
81 "Content-Signal: search=yes, ai-input=yes, ai-train=yes",
82 "Allow: /",
83 ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
84 "",
85 `Sitemap: ${origin}/sitemap.xml`,
86 "",
87 `# For AI assistants: ${origin}/llms.txt indexes the site and the docs as Markdown; ${origin}/llms-full.txt holds all of it in one file, and ${origin}/llms-small.txt the site and the essential docs.`,
88 "",
89 ];
90 return c.text(lines.join("\n"), 200, { "cache-control": "public, max-age=86400" });
91 });
92 // The IndexNow key file (web/indexnow.ts): /<key>.txt holds the key, which proves submissions come from this site.
93 web.get("/:file{[0-9a-f]+\\.txt}", async (c) => {
94 const key = await indexNowKey(c.env);
95 if (!key || c.req.param("file") !== `${key}.txt`) return c.notFound();
96 return c.text(key, 200, { "cache-control": "public, max-age=3600" });
97 });
98 web.get("/sitemap.xml", async (c) => {
99 const origin = c.env.PUBLIC_URL.replace(/\/$/, "");
100 const repos = await c.get("dir").publicRepos(5000);
101 const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
102 const url = (loc: string, lastmod?: string | null) => `<url><loc>${esc(loc)}</loc>${lastmod ? `<lastmod>${esc(lastmod.slice(0, 10))}</lastmod>` : ""}</url>`;
103 const body = [
104 '<?xml version="1.0" encoding="UTF-8"?>',
105 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
106 // The home page, then the product, comparison and company pages (web/site.tsx).
107 ...siteSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
108 ...docsSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
109 ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)),
110 "</urlset>",
111 ].join("\n");
112 return c.body(body, 200, { "content-type": "application/xml; charset=utf-8", "cache-control": "public, max-age=3600" });
113 });
114}
115
116export type SeoOpts = {
117 title: string;
118 description?: string;
119 /** Keep the page out of search results. */
120 noindex?: boolean;
121 /** The home page. */
122 home?: boolean;
123 /** Structured data (schema.org objects, without @context), as one JSON-LD graph. */
124 jsonLd?: object[];
125 /** The date an article's facts were last checked (YYYY-MM-DD): marks the page as an article. */
126 modified?: string;
127 nonce: string;
128};
129
130/** Icons plus search and social meta tags for the page's <head>. */
131export function SeoHead(c: AppContext, o: SeoOpts) {
132 const origin = c.env.PUBLIC_URL.replace(/\/$/, "");
133 const path = c.req.path;
134 const url = `${origin}${path}`;
135 const description = o.description?.trim() || SITE.description;
136 const noindex = o.noindex || PRIVATE.test(path);
137 const image = `${origin}/og-image.png`;
138 const ld = o.jsonLd?.length && !noindex ? JSON.stringify({ "@context": "https://schema.org", "@graph": o.jsonLd }).replace(/</g, "\\u003c") : null;
139 return (
140 <>
141 <meta name="description" content={description} />
142 {noindex ? <meta name="robots" content="noindex, nofollow" /> : <link rel="canonical" href={url} />}
143 <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
144 <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
145 <link rel="icon" href="/favicon-48.png" sizes="48x48" type="image/png" />
146 <link rel="icon" href="/favicon-96.png" sizes="96x96" type="image/png" />
147 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
148 <link rel="manifest" href="/site.webmanifest" />
149 <meta name="theme-color" content={SITE.color} />
150 <meta name="application-name" content={SITE.name} />
151 <meta name="apple-mobile-web-app-title" content={SITE.name} />
152 <meta property="og:type" content={o.modified ? "article" : "website"} />
153 {o.modified ? <meta property="article:modified_time" content={o.modified} /> : null}
154 <meta property="og:site_name" content={SITE.name} />
155 <meta property="og:title" content={o.title} />
156 <meta property="og:description" content={description} />
157 <meta property="og:url" content={url} />
158 <meta property="og:image" content={image} />
159 <meta property="og:image:width" content="1200" />
160 <meta property="og:image:height" content="630" />
161 <meta property="og:image:alt" content={`${SITE.name}: ${SITE.tagline}`} />
162 <meta name="twitter:card" content="summary_large_image" />
163 <meta name="twitter:title" content={o.title} />
164 <meta name="twitter:description" content={description} />
165 <meta name="twitter:image" content={image} />
166 {ld ? <script type="application/ld+json" nonce={o.nonce}>{raw(ld)}</script> : null}
167 </>
168 );
169}