| 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 | */ |
| 10 | import { raw } from "hono/html"; |
| 11 | import type { AppContext } from "../auth/principal.js"; |
| 12 | import type { WebApp } from "./ui.js"; |
| 13 | import { ICON_FILES } from "./icons.generated.js"; |
| 14 | import { docsSitemapEntries } from "../docs/web.js"; |
| 15 | import { CONTACT, siteSitemapEntries } from "./site.js"; |
| 16 | import { indexNowKey } from "./indexnow.js"; |
| 17 | |
| 18 | export 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. */ |
| 27 | export 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 | |
| 29 | const TYPES: Record<string, string> = { ico: "image/x-icon", png: "image/png" }; |
| 30 | const bytes = new Map<string, Uint8Array<ArrayBuffer>>(); |
| 31 | function 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 | |
| 44 | const CACHE = "public, max-age=604800"; |
| 45 | |
| 46 | /** Paths that are never useful in search results. */ |
| 47 | const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|contact\/confirm|-)(\/|$)/; |
| 48 | |
| 49 | export 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", "/contact/confirm", "/-/", "/*.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, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 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 | // The contact form (web/contact.tsx), for people and AI assistants looking for how to reach the team. |
| 109 | url(`${origin}${CONTACT.path}`, CONTACT.updated), |
| 110 | ...docsSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)), |
| 111 | ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)), |
| 112 | "</urlset>", |
| 113 | ].join("\n"); |
| 114 | return c.body(body, 200, { "content-type": "application/xml; charset=utf-8", "cache-control": "public, max-age=3600" }); |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | export type SeoOpts = { |
| 119 | title: string; |
| 120 | description?: string; |
| 121 | /** Keep the page out of search results. */ |
| 122 | noindex?: boolean; |
| 123 | /** The home page. */ |
| 124 | home?: boolean; |
| 125 | /** Structured data (schema.org objects, without @context), as one JSON-LD graph. */ |
| 126 | jsonLd?: object[]; |
| 127 | /** The date an article's facts were last checked (YYYY-MM-DD): marks the page as an article. */ |
| 128 | modified?: string; |
| 129 | nonce: string; |
| 130 | }; |
| 131 | |
| 132 | /** Icons plus search and social meta tags for the page's <head>. */ |
| 133 | export function SeoHead(c: AppContext, o: SeoOpts) { |
| 134 | const origin = c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 135 | const path = c.req.path; |
| 136 | const url = `${origin}${path}`; |
| 137 | const description = o.description?.trim() || SITE.description; |
| 138 | const noindex = o.noindex || PRIVATE.test(path); |
| 139 | const image = `${origin}/og-image.png`; |
| 140 | const ld = o.jsonLd?.length && !noindex ? JSON.stringify({ "@context": "https://schema.org", "@graph": o.jsonLd }).replace(/</g, "\\u003c") : null; |
| 141 | return ( |
| 142 | <> |
| 143 | <meta name="description" content={description} /> |
| 144 | {noindex ? <meta name="robots" content="noindex, nofollow" /> : <link rel="canonical" href={url} />} |
| 145 | <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" /> |
| 146 | <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> |
| 147 | <link rel="icon" href="/favicon-48.png" sizes="48x48" type="image/png" /> |
| 148 | <link rel="icon" href="/favicon-96.png" sizes="96x96" type="image/png" /> |
| 149 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> |
| 150 | <link rel="manifest" href="/site.webmanifest" /> |
| 151 | <meta name="theme-color" content={SITE.color} /> |
| 152 | <meta name="application-name" content={SITE.name} /> |
| 153 | <meta name="apple-mobile-web-app-title" content={SITE.name} /> |
| 154 | <meta property="og:type" content={o.modified ? "article" : "website"} /> |
| 155 | {o.modified ? <meta property="article:modified_time" content={o.modified} /> : null} |
| 156 | <meta property="og:site_name" content={SITE.name} /> |
| 157 | <meta property="og:title" content={o.title} /> |
| 158 | <meta property="og:description" content={description} /> |
| 159 | <meta property="og:url" content={url} /> |
| 160 | <meta property="og:image" content={image} /> |
| 161 | <meta property="og:image:width" content="1200" /> |
| 162 | <meta property="og:image:height" content="630" /> |
| 163 | <meta property="og:image:alt" content={`${SITE.name}: ${SITE.tagline}`} /> |
| 164 | <meta name="twitter:card" content="summary_large_image" /> |
| 165 | <meta name="twitter:title" content={o.title} /> |
| 166 | <meta name="twitter:description" content={description} /> |
| 167 | <meta name="twitter:image" content={image} /> |
| 168 | {ld ? <script type="application/ld+json" nonce={o.nonce}>{raw(ld)}</script> : null} |
| 169 | </> |
| 170 | ); |
| 171 | } |