g1t/apps/web/app/lib/meta.ts

164 lines6,742 bytesCodeBlame
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 "The open-source git platform where people and agents ship software together, from the first issue to production on the edge. Priced at cost plus 20%, 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};
46
47type ProjectData = {
48 repo?: { description?: string | null };
49 project?: { name?: string; description?: string | null; descriptionInherited?: boolean } | null;
50 open?: { issues?: number; pulls?: number };
51};
52type WorkspaceData = { workspace?: { name?: string; description?: string | null; avatar?: string | null } };
53
54function loaded<T>(args: PageArgs, id: string): T | undefined {
55 return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined;
56}
57
58/** A short, stable fingerprint, for the card's `v`. */
59export function fingerprint(value: unknown): string {
60 const text = JSON.stringify(value) ?? "";
61 // FNV-1a, 32 bits.
62 let hash = 0x811c9dc5;
63 for (let i = 0; i < text.length; i++) {
64 hash ^= text.charCodeAt(i);
65 hash = Math.imul(hash, 0x01000193);
66 }
67 return (hash >>> 0).toString(36);
68}
69
70/** A description fit for a preview: one line, at most 200 characters. */
71function summary(text: string): string {
72 const line = text.replace(/\s+/g, " ").trim();
73 return line.length > 200 ? `${line.slice(0, 199).trimEnd()}…` : line;
74}
75
76/** The opening of some Markdown as plain text, for a description. */
77export function excerpt(markdown: string | null | undefined, max = 160): string {
78 const text = (markdown ?? "")
79 .replace(/```[\s\S]*?(```|$)/g, " ")
80 .replace(/<[^>]+>/g, " ")
81 .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
82 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
83 .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
84 .replace(/[`*_~|]/g, "")
85 .replace(/\s+/g, " ")
86 .trim();
87 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
88}
89
90/**
91 * The address of a page's card. Its `v` always carries the cards' render
92 * version, so a new card design is a new address for og's cache, browsers
93 * and every site that has fetched the old card; then, when the page has one,
94 * a fingerprint of what the card shows.
95 */
96export function cardUrl(pathname: string, version?: unknown, og: string = OG): string {
97 const url = new URL("/image", og);
98 url.searchParams.set("path", pathname);
99 url.searchParams.set("v", ogVersion(version === undefined ? undefined : fingerprint(version)));
100 return url.toString();
101}
102
103/** A project's own description; null while it follows its repository's. */
104function ownDescription(project: { description?: string | null; descriptionInherited?: boolean } | null | undefined): string | null {
105 return project && !project.descriptionInherited ? (project.description ?? null) : null;
106}
107
108export function page(args: PageArgs, meta: PageMeta): MetaDescriptor[] {
109 const project = loaded<ProjectData>(args, "routes/repo/layout");
110 const workspace = loaded<WorkspaceData>(args, "routes/workspace/layout");
111 const description = summary(
112 meta.description ||
113 ownDescription(project?.project) ||
114 project?.repo?.description ||
115 workspace?.workspace?.description ||
116 DESCRIPTION,
117 );
118 const version =
119 meta.version ??
120 (project
121 ? [project.project?.name, ownDescription(project.project) ?? project.repo?.description, project.open]
122 : workspace
123 ? [
124 workspace.workspace?.name,
125 workspace.workspace?.description,
126 // Only when there is one, so cards without an icon keep their address.
127 ...(workspace.workspace?.avatar ? [workspace.workspace.avatar] : []),
128 ]
129 : undefined);
130 const pathname = args.location.pathname.replace(/\/+$/, "") || "/";
131 // The title a preview shows needs no "· g1t": the site name is beside it.
132 const shareTitle = meta.title.replace(/ · g1t$/, "");
133 const addresses = loaded<{ addresses?: Pick<Addresses, "site" | "og"> }>(args, "root")?.addresses;
134 const site = addresses?.site ?? SITE;
135 const og = addresses ? addresses.og : OG;
136 const image = og ? cardUrl(pathname, version, og) : null;
137 return [
138 { title: meta.title },
139 { name: "description", content: description },
140 { property: "og:site_name", content: "g1t" },
141 { property: "og:type", content: meta.type ?? "website" },
142 { property: "og:url", content: `${site}${pathname === "/" ? "/" : pathname}` },
143 { property: "og:title", content: shareTitle },
144 { property: "og:description", content: description },
145 ...(image
146 ? [
147 { property: "og:image", content: image },
148 { property: "og:image:type", content: "image/png" },
149 { property: "og:image:width", content: "1200" },
150 { property: "og:image:height", content: "630" },
151 { property: "og:image:alt", content: shareTitle },
152 ]
153 : []),
154 { name: "twitter:card", content: image ? "summary_large_image" : "summary" },
155 { name: "twitter:title", content: shareTitle },
156 { name: "twitter:description", content: description },
157 ...(image
158 ? [
159 { name: "twitter:image", content: image },
160 { name: "twitter:image:alt", content: shareTitle },
161 ]
162 : []),
163 ];
164}