Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

161 linesCodeBlameRaw
1/**
2 * Every page's <title> and the tags a link preview reads: its description,
3 * Open Graph and Twitter's card. The image is the page's own card from the
4 * og service (services/og), which draws it as an anonymous visitor would
5 * see the page, so a private page's card never names anything. A g1t without
6 * the og service (`OG_URL` set empty) leaves the image tags out.
7 *
8 * React Router renders only the deepest route's `meta`, so each route
9 * returns `page(args, …)` rather than a bare title.
10 */
11import type { MetaDescriptor } from "react-router";
12import { ogVersion } from "@g1t/contracts/og";
13
14import type { Addresses } from "./addresses";
15
16/**
17 * g1t.sh's site and card service, the same as HOSTED_ADDRESSES in
18 * ./addresses (kept here so this module has no imports to run in tests).
19 * Pages use the addresses in the root loader's data when it has them.
20 */
21export const SITE = "https://g1t.sh";
22export const OG = "https://og.g1t.sh";
23
24/** What g1t is, for pages with nothing more particular to say. */
25export const DESCRIPTION =
26 "Repositories, pull requests, required checks, a merge queue and deployments, with chat for your team. Connect the coding agents you already use through MCP and the API. Open source, never per seat.";
27
28/** As much of `MetaArgs` as the tags need. */
29export type PageArgs = {
30 location: { pathname: string };
31 matches: ReadonlyArray<{ id: string; loaderData?: unknown } | undefined>;
32};
33
34export type PageMeta = {
35 /** The whole <title>, ending in "· g1t". */
36 title: string;
37 /** Defaults to the project's or workspace's description, then g1t's. */
38 description?: string | null;
39 /**
40 * Whatever the page's card shows, so that the card is drawn again when it
41 * changes. Defaults to the project's details on project pages.
42 */
43 version?: unknown;
44 type?: "website" | "article";
45 /** Stable public landing pages may opt into a self-referencing canonical. */
46 canonical?: boolean;
47};
48
49type ProjectData = {
50 /** Only projects have descriptions; a repository has none. */
51 project?: { name?: string; description?: string | null } | null;
52 open?: { issues?: number; pulls?: number };
53};
54type WorkspaceData = { workspace?: { name?: string; description?: string | null; avatar?: string | null } };
55
56function loaded<T>(args: PageArgs, id: string): T | undefined {
57 return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined;
58}
59
60/** A short, stable fingerprint, for the card's `v`. */
61export function fingerprint(value: unknown): string {
62 const text = JSON.stringify(value) ?? "";
63 // FNV-1a, 32 bits.
64 let hash = 0x811c9dc5;
65 for (let i = 0; i < text.length; i++) {
66 hash ^= text.charCodeAt(i);
67 hash = Math.imul(hash, 0x01000193);
68 }
69 return (hash >>> 0).toString(36);
70}
71
72/** A description fit for a preview: one line, at most 200 characters. */
73function summary(text: string): string {
74 const line = text.replace(/\s+/g, " ").trim();
75 return line.length > 200 ? `${line.slice(0, 199).trimEnd()}…` : line;
76}
77
78/** The opening of some Markdown as plain text, for a description. */
79export function excerpt(markdown: string | null | undefined, max = 160): string {
80 const text = (markdown ?? "")
81 .replace(/```[\s\S]*?(```|$)/g, " ")
82 .replace(/<[^>]+>/g, " ")
83 .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
84 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
85 .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
86 .replace(/[`*_~|]/g, "")
87 .replace(/\s+/g, " ")
88 .trim();
89 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
90}
91
92/**
93 * The address of a page's card. Its `v` always carries the cards' render
94 * version, so a new card design is a new address for og's cache, browsers
95 * and every site that has fetched the old card; then, when the page has one,
96 * a fingerprint of what the card shows.
97 */
98export function cardUrl(pathname: string, version?: unknown, og: string = OG): string {
99 const url = new URL("/image", og);
100 url.searchParams.set("path", pathname);
101 url.searchParams.set("v", ogVersion(version === undefined ? undefined : fingerprint(version)));
102 return url.toString();
103}
104
105export function page(args: PageArgs, meta: PageMeta): MetaDescriptor[] {
106 const project = loaded<ProjectData>(args, "routes/repo/layout");
107 const workspace = loaded<WorkspaceData>(args, "routes/workspace/layout");
108 const description = summary(
109 meta.description ||
110 project?.project?.description ||
111 workspace?.workspace?.description ||
112 DESCRIPTION,
113 );
114 const version =
115 meta.version ??
116 (project
117 ? [project.project?.name, project.project?.description ?? null, project.open]
118 : workspace
119 ? [
120 workspace.workspace?.name,
121 workspace.workspace?.description,
122 // Only when there is one, so cards without an icon keep their address.
123 ...(workspace.workspace?.avatar ? [workspace.workspace.avatar] : []),
124 ]
125 : undefined);
126 const pathname = args.location.pathname.replace(/\/+$/, "") || "/";
127 // The title a preview shows needs no "· g1t": the site name is beside it.
128 const shareTitle = meta.title.replace(/ · g1t$/, "");
129 const addresses = loaded<{ addresses?: Pick<Addresses, "site" | "og"> }>(args, "root")?.addresses;
130 const site = addresses?.site ?? SITE;
131 const og = addresses ? addresses.og : OG;
132 const image = og ? cardUrl(pathname, version, og) : null;
133 return [
134 { title: meta.title },
135 ...(pathname === "/" || meta.canonical ? [{ tagName: "link" as const, rel: "canonical", href: `${site}${pathname}` }] : []),
136 { name: "description", content: description },
137 { property: "og:site_name", content: "g1t" },
138 { property: "og:type", content: meta.type ?? "website" },
139 { property: "og:url", content: `${site}${pathname === "/" ? "/" : pathname}` },
140 { property: "og:title", content: shareTitle },
141 { property: "og:description", content: description },
142 ...(image
143 ? [
144 { property: "og:image", content: image },
145 { property: "og:image:type", content: "image/png" },
146 { property: "og:image:width", content: "1200" },
147 { property: "og:image:height", content: "630" },
148 { property: "og:image:alt", content: shareTitle },
149 ]
150 : []),
151 { name: "twitter:card", content: image ? "summary_large_image" : "summary" },
152 { name: "twitter:title", content: shareTitle },
153 { name: "twitter:description", content: description },
154 ...(image
155 ? [
156 { name: "twitter:image", content: image },
157 { name: "twitter:image:alt", content: shareTitle },
158 ]
159 : []),
160 ];
161}