Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
- Social cards for every page: og.g1t.shChase Pierceb1efcf2
| 46 | 46 | - `g1t-mark.svg`, `g1t-mark.png` (256px): the mark on a rounded dark tile, readable on any background | |
| 47 | 47 | - `g1t-logo.svg`, `g1t-logo.png`: the lockup for light backgrounds, used in g1t's emails | |
| 48 | 48 | - `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png`: the lockup for dark backgrounds | |
| 49 | + | - `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails | |
| 50 | + | ||
| 51 | + | ## Social cards | |
| 52 | + | ||
| 53 | + | Every page of g1t.sh and docs.g1t.sh links to its own card at `https://og.g1t.sh`, | |
| 54 | + | drawn by `services/og` in the same colours, type and mark: the lockup and the | |
| 55 | + | page's address along the top, its subject in the middle, its facts along the | |
| 56 | + | bottom, the fleet standing large and faded off the right edge. |
| 97 | 97 | | `services/work` | Issues, pull requests, reviews, check runs and sessions. Rust. | | |
| 98 | 98 | | `services/events` | The event bus and its log. Rust. | | |
| 99 | 99 | | `services/runner` | Starts sandboxes: for g1t agents, and for acceptance checks. | | |
| 100 | + | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. | | |
| 100 | 101 | | `crates/runner` | The program inside a sandbox: runs an agent, or a set of checks, and reports back. Rust. | | |
| 101 | 102 | | `crates/contracts` | Types and service interfaces for the Rust services. | | |
| 102 | 103 | | `crates/kit` | Plumbing shared by Rust services on Workers. | |
| 2 | 2 | ||
| 3 | 3 | import { tabFor } from './tabs'; | |
| 4 | 4 | ||
| 5 | − | type Entry = { type: string; href?: string; entries?: Entry[] }; | |
| 5 | + | type Entry = { type: string; label?: string; href?: string; isCurrent?: boolean; entries?: Entry[] }; | |
| 6 | + | ||
| 7 | + | /** The social cards' service; see services/og. */ | |
| 8 | + | const OG = 'https://og.g1t.sh'; | |
| 6 | 9 | ||
| 7 | 10 | /** The slug of a group's first page on this site, such as `reference/api`. */ | |
| 8 | 11 | function firstSlug(entries: Entry[]): string | undefined { | |
| 17 | 20 | return undefined; | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 23 | + | /** The label of the sidebar group holding the current page, the innermost if they nest. */ | |
| 24 | + | function sectionOf(entries: Entry[], group?: string): string | undefined { | |
| 25 | + | for (const entry of entries) { | |
| 26 | + | if (entry.type === 'group') { | |
| 27 | + | const found = sectionOf(entry.entries ?? [], entry.label); | |
| 28 | + | if (found !== undefined) return found; | |
| 29 | + | } else if (entry.isCurrent) { | |
| 30 | + | return group; | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | return undefined; | |
| 34 | + | } | |
| 35 | + | ||
| 20 | 36 | /** | |
| 21 | 37 | * Each page's sidebar holds only its tab's groups: those whose pages | |
| 22 | 38 | * belong to the same tab. Groups are told apart by their pages, not their | |
| 23 | 39 | * labels, since a guide group and an API group can share a name. | |
| 40 | + | * | |
| 41 | + | * Each page also gets its own social card: its title, its section and its | |
| 42 | + | * description, drawn by the og service. | |
| 24 | 43 | */ | |
| 25 | 44 | export const onRequest = defineRouteMiddleware((context) => { | |
| 26 | 45 | const route = context.locals.starlightRoute; | |
| 46 | + | const section = sectionOf(route.sidebar as Entry[]); | |
| 27 | 47 | const tab = tabFor(route.id); | |
| 28 | 48 | route.sidebar = route.sidebar.filter((entry) => { | |
| 29 | 49 | if (entry.type !== 'group') return false; | |
| 30 | 50 | const slug = firstSlug(entry.entries as Entry[]); | |
| 31 | 51 | return slug !== undefined && tabFor(slug) === tab; | |
| 32 | 52 | }); | |
| 53 | + | ||
| 54 | + | const { title, description } = route.entry.data; | |
| 55 | + | const card = new URL('/docs', OG); | |
| 56 | + | card.searchParams.set('title', title); | |
| 57 | + | if (section) card.searchParams.set('section', section); | |
| 58 | + | if (description) card.searchParams.set('description', description); | |
| 59 | + | const image = card.toString(); | |
| 60 | + | const alt = `${title} · g1t docs`; | |
| 61 | + | const meta = (key: 'property' | 'name', name: string, content: string) => ({ | |
| 62 | + | tag: 'meta' as const, | |
| 63 | + | attrs: { [key]: name, content }, | |
| 64 | + | }); | |
| 65 | + | // Starlight already writes og:title, og:description, og:url and | |
| 66 | + | // twitter:card; these finish the card. | |
| 67 | + | route.head.push( | |
| 68 | + | meta('property', 'og:image', image), | |
| 69 | + | meta('property', 'og:image:type', 'image/png'), | |
| 70 | + | meta('property', 'og:image:width', '1200'), | |
| 71 | + | meta('property', 'og:image:height', '630'), | |
| 72 | + | meta('property', 'og:image:alt', alt), | |
| 73 | + | meta('name', 'twitter:title', title), | |
| 74 | + | meta('name', 'twitter:image', image), | |
| 75 | + | meta('name', 'twitter:image:alt', alt), | |
| 76 | + | ); | |
| 77 | + | if (description) route.head.push(meta('name', 'twitter:description', description)); | |
| 33 | 78 | }); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DESCRIPTION, cardUrl, excerpt, fingerprint, page } from "./meta.ts"; | |
| 5 | + | ||
| 6 | + | type Tag = Record<string, string>; | |
| 7 | + | ||
| 8 | + | function tags(list: unknown[]): Map<string, string> { | |
| 9 | + | const found = new Map<string, string>(); | |
| 10 | + | for (const tag of list as Tag[]) { | |
| 11 | + | if ("title" in tag) found.set("title", tag.title); | |
| 12 | + | else found.set(tag.property ?? tag.name, tag.content); | |
| 13 | + | } | |
| 14 | + | return found; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | test("a page gets its title, description, Open Graph and Twitter tags", () => { | |
| 18 | + | const meta = tags(page({ location: { pathname: "/pricing" }, matches: [] }, { title: "Pricing · g1t", description: "What it costs." })); | |
| 19 | + | assert.equal(meta.get("title"), "Pricing · g1t"); | |
| 20 | + | assert.equal(meta.get("description"), "What it costs."); | |
| 21 | + | assert.equal(meta.get("og:title"), "Pricing"); | |
| 22 | + | assert.equal(meta.get("og:url"), "https://g1t.sh/pricing"); | |
| 23 | + | assert.equal(meta.get("og:site_name"), "g1t"); | |
| 24 | + | assert.equal(meta.get("og:image"), "https://og.g1t.sh/image?path=%2Fpricing"); | |
| 25 | + | assert.equal(meta.get("twitter:card"), "summary_large_image"); | |
| 26 | + | assert.equal(meta.get("twitter:image"), meta.get("og:image")); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("pages under a project describe the project, and their card follows its details", () => { | |
| 30 | + | const project = { | |
| 31 | + | id: "routes/repo/layout", | |
| 32 | + | loaderData: { repo: { description: "Repo words" }, project: { name: "Web", description: "The storefront" }, open: { issues: 2, pulls: 1 } }, | |
| 33 | + | }; | |
| 34 | + | const meta = tags(page({ location: { pathname: "/acme/web/commits/" }, matches: [project] }, { title: "Commits · acme/web · g1t" })); | |
| 35 | + | assert.equal(meta.get("description"), "The storefront"); | |
| 36 | + | assert.equal(meta.get("og:url"), "https://g1t.sh/acme/web/commits"); | |
| 37 | + | assert.match(meta.get("og:image") ?? "", /\?path=%2Facme%2Fweb%2Fcommits&v=[0-9a-z]+$/); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("with nothing particular to say, a page says what g1t is", () => { | |
| 41 | + | const meta = tags(page({ location: { pathname: "/login" }, matches: [] }, { title: "Sign in · g1t" })); | |
| 42 | + | assert.equal(meta.get("description"), DESCRIPTION); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("a card's version changes with what it shows", () => { | |
| 46 | + | assert.equal(fingerprint(["a", "open"]), fingerprint(["a", "open"])); | |
| 47 | + | assert.notEqual(fingerprint(["a", "open"]), fingerprint(["a", "merged"])); | |
| 48 | + | assert.equal(cardUrl("/", undefined), "https://og.g1t.sh/image?path=%2F"); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("an excerpt is plain text from the start of the Markdown", () => { | |
| 52 | + | assert.equal(excerpt("## Why\n\nThe [queue](https://x) is **slow**.\n\n```ts\ncode()\n```"), "Why The queue is slow."); | |
| 53 | + | assert.equal(excerpt("x".repeat(300), 10).length, 10); | |
| 54 | + | assert.equal(excerpt(null), ""); | |
| 55 | + | }); |
| 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. | |
| 6 | + | * | |
| 7 | + | * React Router renders only the deepest route's `meta`, so each route | |
| 8 | + | * returns `page(args, …)` rather than a bare title. | |
| 9 | + | */ | |
| 10 | + | import type { MetaDescriptor } from "react-router"; | |
| 11 | + | ||
| 12 | + | export const SITE = "https://g1t.sh"; | |
| 13 | + | export const OG = "https://og.g1t.sh"; | |
| 14 | + | ||
| 15 | + | /** What g1t is, for pages with nothing more particular to say. */ | |
| 16 | + | export const DESCRIPTION = | |
| 17 | + | "A git forge for teams of agents: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare."; | |
| 18 | + | ||
| 19 | + | /** As much of `MetaArgs` as the tags need. */ | |
| 20 | + | export type PageArgs = { | |
| 21 | + | location: { pathname: string }; | |
| 22 | + | matches: ReadonlyArray<{ id: string; loaderData?: unknown } | undefined>; | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | export type PageMeta = { | |
| 26 | + | /** The whole <title>, ending in "· g1t". */ | |
| 27 | + | title: string; | |
| 28 | + | /** Defaults to the project's or workspace's description, then g1t's. */ | |
| 29 | + | description?: string | null; | |
| 30 | + | /** | |
| 31 | + | * Whatever the page's card shows, so that the card is drawn again when it | |
| 32 | + | * changes. Defaults to the project's details on project pages. | |
| 33 | + | */ | |
| 34 | + | version?: unknown; | |
| 35 | + | type?: "website" | "article"; | |
| 36 | + | }; | |
| 37 | + | ||
| 38 | + | type ProjectData = { | |
| 39 | + | repo?: { description?: string | null }; | |
| 40 | + | project?: { name?: string; description?: string | null } | null; | |
| 41 | + | open?: { issues?: number; pulls?: number }; | |
| 42 | + | }; | |
| 43 | + | type WorkspaceData = { workspace?: { name?: string; description?: string | null } }; | |
| 44 | + | ||
| 45 | + | function loaded<T>(args: PageArgs, id: string): T | undefined { | |
| 46 | + | return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** A short, stable fingerprint, for the card's `v`. */ | |
| 50 | + | export function fingerprint(value: unknown): string { | |
| 51 | + | const text = JSON.stringify(value) ?? ""; | |
| 52 | + | // FNV-1a, 32 bits. | |
| 53 | + | let hash = 0x811c9dc5; | |
| 54 | + | for (let i = 0; i < text.length; i++) { | |
| 55 | + | hash ^= text.charCodeAt(i); | |
| 56 | + | hash = Math.imul(hash, 0x01000193); | |
| 57 | + | } | |
| 58 | + | return (hash >>> 0).toString(36); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A description fit for a preview: one line, at most 200 characters. */ | |
| 62 | + | function summary(text: string): string { | |
| 63 | + | const line = text.replace(/\s+/g, " ").trim(); | |
| 64 | + | return line.length > 200 ? `${line.slice(0, 199).trimEnd()}…` : line; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** The opening of some Markdown as plain text, for a description. */ | |
| 68 | + | export function excerpt(markdown: string | null | undefined, max = 160): string { | |
| 69 | + | const text = (markdown ?? "") | |
| 70 | + | .replace(/```[\s\S]*?(```|$)/g, " ") | |
| 71 | + | .replace(/<[^>]+>/g, " ") | |
| 72 | + | .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") | |
| 73 | + | .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") | |
| 74 | + | .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "") | |
| 75 | + | .replace(/[`*_~|]/g, "") | |
| 76 | + | .replace(/\s+/g, " ") | |
| 77 | + | .trim(); | |
| 78 | + | return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** The address of a page's card. */ | |
| 82 | + | export function cardUrl(pathname: string, version?: unknown): string { | |
| 83 | + | const url = new URL("/image", OG); | |
| 84 | + | url.searchParams.set("path", pathname); | |
| 85 | + | if (version !== undefined) url.searchParams.set("v", fingerprint(version)); | |
| 86 | + | return url.toString(); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | export function page(args: PageArgs, meta: PageMeta): MetaDescriptor[] { | |
| 90 | + | const project = loaded<ProjectData>(args, "routes/repo/layout"); | |
| 91 | + | const workspace = loaded<WorkspaceData>(args, "routes/workspace/layout"); | |
| 92 | + | const description = summary( | |
| 93 | + | meta.description || | |
| 94 | + | project?.project?.description || | |
| 95 | + | project?.repo?.description || | |
| 96 | + | workspace?.workspace?.description || | |
| 97 | + | DESCRIPTION, | |
| 98 | + | ); | |
| 99 | + | const version = | |
| 100 | + | meta.version ?? | |
| 101 | + | (project | |
| 102 | + | ? [project.project?.name, project.project?.description ?? project.repo?.description, project.open] | |
| 103 | + | : workspace | |
| 104 | + | ? [workspace.workspace?.name, workspace.workspace?.description] | |
| 105 | + | : undefined); | |
| 106 | + | const pathname = args.location.pathname.replace(/\/+$/, "") || "/"; | |
| 107 | + | // The title a preview shows needs no "· g1t": the site name is beside it. | |
| 108 | + | const shareTitle = meta.title.replace(/ · g1t$/, ""); | |
| 109 | + | const image = cardUrl(pathname, version); | |
| 110 | + | return [ | |
| 111 | + | { title: meta.title }, | |
| 112 | + | { name: "description", content: description }, | |
| 113 | + | { property: "og:site_name", content: "g1t" }, | |
| 114 | + | { property: "og:type", content: meta.type ?? "website" }, | |
| 115 | + | { property: "og:url", content: `${SITE}${pathname === "/" ? "/" : pathname}` }, | |
| 116 | + | { property: "og:title", content: shareTitle }, | |
| 117 | + | { property: "og:description", content: description }, | |
| 118 | + | { property: "og:image", content: image }, | |
| 119 | + | { property: "og:image:type", content: "image/png" }, | |
| 120 | + | { property: "og:image:width", content: "1200" }, | |
| 121 | + | { property: "og:image:height", content: "630" }, | |
| 122 | + | { property: "og:image:alt", content: shareTitle }, | |
| 123 | + | { name: "twitter:card", content: "summary_large_image" }, | |
| 124 | + | { name: "twitter:title", content: shareTitle }, | |
| 125 | + | { name: "twitter:description", content: description }, | |
| 126 | + | { name: "twitter:image", content: image }, | |
| 127 | + | { name: "twitter:image:alt", content: shareTitle }, | |
| 128 | + | ]; | |
| 129 | + | } |
| 2 | 2 | import { Form } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/device"; | |
| 5 | + | import { page } from "../lib/meta"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({}: Route.MetaArgs) { | |
| 10 | − | return [{ title: "Connect an application · g1t" }]; | |
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: "Connect an application · g1t" }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | /** Where a tool sends a person to approve its sign-in. */ |
| 2 | 2 | import { Form } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/explore"; | |
| 5 | + | import { page } from "../lib/meta"; | |
| 5 | 6 | import { RepoList } from "../components/repo-list"; | |
| 6 | 7 | import { notACredential } from "../components/ui"; | |
| 7 | 8 | import { repos } from "../lib/services.server"; | |
| 8 | 9 | import { getViewer } from "../lib/session.server"; | |
| 9 | 10 | ||
| 10 | − | export function meta({ loaderData }: Route.MetaArgs) { | |
| 11 | − | return [ | |
| 12 | − | { title: loaderData?.query ? `${loaderData.query} · Search · g1t` : "Explore · g1t" }, | |
| 13 | − | ]; | |
| 11 | + | export function meta({ loaderData, ...args }: Route.MetaArgs) { | |
| 12 | + | return page(args, { | |
| 13 | + | title: loaderData?.query ? `${loaderData.query} · Search · g1t` : "Explore · g1t", | |
| 14 | + | description: loaderData?.query | |
| 15 | + | ? `Public repositories on g1t matching "${loaderData.query}".` | |
| 16 | + | : "Public repositories on g1t, and the agents at work on them.", | |
| 17 | + | }); | |
| 14 | 18 | } | |
| 15 | 19 | ||
| 16 | 20 | /** Serves both /explore and /search?q=. */ |
| 1 | 1 | import { Form, Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/forgot"; | |
| 4 | + | import { page } from "../lib/meta"; | |
| 4 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 6 | import { Button, Field, Input } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin } from "../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({}: Route.MetaArgs) { | |
| 10 | − | return [{ title: "Reset your password · g1t" }]; | |
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: "Reset your password · g1t" }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | export async function action({ request }: Route.ActionArgs) { |
| 6 | 6 | import type { Lifecycle, Pull, Repo } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import type { Route } from "./+types/home"; | |
| 9 | + | import { page } from "../lib/meta"; | |
| 9 | 10 | import { Landing } from "../components/landing"; | |
| 10 | 11 | import type { ShellData } from "../components/shell"; | |
| 11 | 12 | import { STAGE_LABEL, StageDots } from "../components/lifecycle"; | |
| 25 | 26 | ||
| 26 | 27 | const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`; | |
| 27 | 28 | ||
| 28 | − | export function meta({}: Route.MetaArgs) { | |
| 29 | − | return [ | |
| 30 | − | { title: "g1t — Git for AI scale" }, | |
| 31 | − | { | |
| 32 | − | name: "description", | |
| 33 | − | content: | |
| 34 | − | "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.", | |
| 35 | − | }, | |
| 36 | − | ]; | |
| 29 | + | export function meta(args: Route.MetaArgs) { | |
| 30 | + | return page(args, { | |
| 31 | + | title: "g1t — Git for AI scale", | |
| 32 | + | description: | |
| 33 | + | "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.", | |
| 34 | + | }); | |
| 37 | 35 | } | |
| 38 | 36 | ||
| 39 | 37 | export function headers({ loaderHeaders }: Route.HeadersArgs) { |
| 1 | 1 | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/login"; | |
| 4 | + | import { page } from "../lib/meta"; | |
| 4 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 11 | 12 | startSession, | |
| 12 | 13 | } from "../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({}: Route.MetaArgs) { | |
| 15 | − | return [{ title: "Sign in · g1t" }]; | |
| 15 | + | export function meta(args: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: "Sign in · g1t" }); | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | export function loader({ request, context }: Route.LoaderArgs) { |
| 3 | 3 | import { Form, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/new"; | |
| 6 | + | import { page } from "../lib/meta"; | |
| 6 | 7 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 7 | 8 | import { repos } from "../lib/services.server"; | |
| 8 | 9 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 9 | 10 | ||
| 10 | − | export function meta({}: Route.MetaArgs) { | |
| 11 | − | return [{ title: "New project · g1t" }]; | |
| 11 | + | export function meta(args: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: "New project · g1t" }); | |
| 12 | 13 | } | |
| 13 | 14 | ||
| 14 | 15 | export function loader({ request, context }: Route.LoaderArgs) { |
| 3 | 3 | import { DEPLOYMENTS_ALLOWANCE, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/pricing"; | |
| 6 | + | import { page } from "../lib/meta"; | |
| 6 | 7 | import { TimeAgo } from "../components/ui"; | |
| 7 | 8 | import { billing } from "../lib/services.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta() { | |
| 10 | − | return [ | |
| 11 | − | { title: "Pricing · g1t" }, | |
| 12 | − | { | |
| 13 | − | name: "description", | |
| 14 | − | content: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus a set markup. No seats.", | |
| 15 | − | }, | |
| 16 | − | ]; | |
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { | |
| 12 | + | title: "Pricing · g1t", | |
| 13 | + | description: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus a set markup. No seats.", | |
| 14 | + | }); | |
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | 17 | export async function loader() { |
| 1 | 1 | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/register"; | |
| 4 | + | import { page } from "../lib/meta"; | |
| 4 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 11 | 12 | startSession, | |
| 12 | 13 | } from "../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({}: Route.MetaArgs) { | |
| 15 | − | return [{ title: "Create an account · g1t" }]; | |
| 15 | + | export function meta(args: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: "Create an account · g1t" }); | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | export function loader({ request, context }: Route.LoaderArgs) { |
| 5 | 5 | import type { Annotation, Job, StepState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/actions-run"; | |
| 8 | + | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 9 | 10 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | 11 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 11 | 12 | import { actions } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 15 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 15 | 16 | const run = loaderData?.detail.run; | |
| 16 | − | return [{ title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }]; | |
| 17 | + | return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }); | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 6 | 6 | import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import type { Route } from "./+types/actions"; | |
| 9 | + | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { Notes, StatusIcon, duration, shortRef } from "../../components/actions"; | |
| 10 | 11 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 11 | 12 | import { actions } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({ params }: Route.MetaArgs) { | |
| 15 | − | return [{ title: `Workflows · ${params.owner}/${params.repo} · g1t` }]; | |
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` }); | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/blob"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { BlobView } from "../../components/repo-view"; | |
| 3 | 4 | import { highlight, highlightLines } from "../../lib/highlight.server"; | |
| 4 | 5 | import { repos } from "../../lib/services.server"; | |
| 5 | 6 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 6 | 7 | ||
| 7 | − | export function meta({ params }: Route.MetaArgs) { | |
| 8 | − | return [{ title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }]; | |
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | + | return page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }); | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 6 | 6 | ||
| 7 | 7 | import { parseCommitMessage } from "../../lib/commit-message"; | |
| 8 | 8 | import type { Route } from "./+types/commit"; | |
| 9 | + | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { DiffView } from "../../components/diff-view"; | |
| 10 | 11 | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 11 | 12 | import { immutable } from "../../lib/immutable.server"; | |
| 17 | 18 | return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" }; | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 21 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 21 | 22 | const subject = loaderData?.commit.message.split("\n")[0]; | |
| 22 | − | return [ | |
| 23 | − | { title: `${subject ? `${subject} · ` : ""}${params.hash.slice(0, 7)} · ${params.owner}/${params.repo} · g1t` }, | |
| 24 | − | ]; | |
| 23 | + | return page(args, { | |
| 24 | + | title: `${subject ? `${subject} · ` : ""}${params.hash.slice(0, 7)} · ${params.owner}/${params.repo} · g1t`, | |
| 25 | + | }); | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | /** How far back a short hash is looked for. */ |
| 4 | 4 | import type { Commit } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/commits"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 7 | 8 | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 8 | 9 | import { repos } from "../../lib/services.server"; | |
| 9 | 10 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 10 | 11 | ||
| 11 | 12 | const PAGE_SIZE = 50; | |
| 12 | 13 | ||
| 13 | − | export function meta({ params }: Route.MetaArgs) { | |
| 14 | − | return [{ title: `Commits · ${params.owner}/${params.repo} · g1t` }]; | |
| 14 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | + | return page(args, { title: `Commits · ${params.owner}/${params.repo} · g1t` }); | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 17 | 18 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 2 | 2 | import { Link, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/deployment"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { TimeAgo } from "../../components/ui"; | |
| 6 | 7 | import { deployments } from "../../lib/services.server"; | |
| 7 | 8 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({ params }: Route.MetaArgs) { | |
| 10 | − | return [{ title: `Deployment · ${params.owner}/${params.repo} · g1t` }]; | |
| 10 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: `Deployment · ${params.owner}/${params.repo} · g1t` }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 7 | 7 | import { host, StatusDot } from "../../components/deploy"; | |
| 8 | 8 | ||
| 9 | 9 | import type { Route } from "./+types/deployments"; | |
| 10 | + | import { page } from "../../lib/meta"; | |
| 10 | 11 | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 11 | 12 | import { billing, deployments } from "../../lib/services.server"; | |
| 12 | 13 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({ params }: Route.MetaArgs) { | |
| 15 | − | return [{ title: `Deployments · ${params.owner}/${params.repo} · g1t` }]; | |
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Deployments · ${params.owner}/${params.repo} · g1t` }); | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 4 | 4 | import { PROVIDERS } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/issue-new"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 7 | 8 | import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui"; | |
| 8 | 9 | import { Label } from "../../components/work"; | |
| 9 | 10 | import { integrations, work } from "../../lib/services.server"; | |
| 10 | 11 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 11 | 12 | ||
| 12 | − | export function meta({ params }: Route.MetaArgs) { | |
| 13 | − | return [{ title: `New issue · ${params.owner}/${params.repo} · g1t` }]; | |
| 13 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | + | return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` }); | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | export async function loader({ request, params, context }: Route.LoaderArgs) { |
| 6 | 6 | import { type Pull, PROVIDERS } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import type { Route } from "./+types/issue"; | |
| 9 | + | import { excerpt, page } from "../../lib/meta"; | |
| 9 | 10 | import { Markdown } from "../../components/markdown"; | |
| 10 | 11 | import { | |
| 11 | 12 | Avatar, | |
| 35 | 36 | ||
| 36 | 37 | const REFRESH_MS = 4000; | |
| 37 | 38 | ||
| 38 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 39 | − | const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : ""; | |
| 40 | − | return [{ title: `${title}${params.owner}/${params.repo} · g1t` }]; | |
| 39 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 40 | + | const issue = loaderData?.issue; | |
| 41 | + | const title = issue ? `${issue.title} · Issue #${issue.number} · ` : ""; | |
| 42 | + | const state = issue?.state === "open" ? "Open" : issue?.reason === "not_planned" ? "Closed as not planned" : "Closed"; | |
| 43 | + | const body = excerpt(issue?.body); | |
| 44 | + | return page(args, { | |
| 45 | + | title: `${title}${params.owner}/${params.repo} · g1t`, | |
| 46 | + | description: issue | |
| 47 | + | ? `${state} issue #${issue.number} on ${params.owner}/${params.repo}, opened by ${issue.author.username}.${body ? ` ${body}` : ""}` | |
| 48 | + | : null, | |
| 49 | + | // The card shows the title and the state. | |
| 50 | + | version: issue ? [issue.title, issue.state, issue.reason] : undefined, | |
| 51 | + | type: "article", | |
| 52 | + | }); | |
| 41 | 53 | } | |
| 42 | 54 | ||
| 43 | 55 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 3 | 3 | import { Form, Link, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/issues"; | |
| 6 | + | import { page } from "../../lib/meta"; | |
| 6 | 7 | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 7 | 8 | import { | |
| 8 | 9 | Assignee, | |
| 17 | 18 | /** As many sandboxes as one request may start. */ | |
| 18 | 19 | const MAX_ASSIGNED_AT_ONCE = 10; | |
| 19 | 20 | ||
| 20 | − | export function meta({ params }: Route.MetaArgs) { | |
| 21 | − | return [{ title: `Issues · ${params.owner}/${params.repo} · g1t` }]; | |
| 21 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 22 | + | return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` }); | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 4 | 4 | import type { Project } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/layout"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 7 | 8 | import { Pill, TabLink as Tab } from "../../components/ui"; | |
| 8 | 9 | import { projects, repos, work } from "../../lib/services.server"; | |
| 9 | 10 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 10 | 11 | ||
| 11 | − | export function meta({ loaderData: loaded, params }: Route.MetaArgs) { | |
| 12 | − | return [{ title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` }]; | |
| 12 | + | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 13 | + | return page(args, { title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` }); | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 5 | 5 | import { Form, Link, data, useNavigation, useRevalidator } from "react-router"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/plan"; | |
| 8 | + | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { Markdown } from "../../components/markdown"; | |
| 9 | 10 | import { Activity, Exchanges } from "../../components/activity"; | |
| 10 | 11 | import { Outcome } from "../../components/outcome"; | |
| 21 | 22 | ||
| 22 | 23 | const REFRESH_MS = 4000; | |
| 23 | 24 | ||
| 24 | − | export function meta({ params }: Route.MetaArgs) { | |
| 25 | − | return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }]; | |
| 25 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 26 | + | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 5 | 5 | import type { Plan } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/plans"; | |
| 8 | + | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { Button, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui"; | |
| 9 | 10 | import { work } from "../../lib/services.server"; | |
| 10 | 11 | import { | |
| 15 | 16 | unwrap, | |
| 16 | 17 | } from "../../lib/session.server"; | |
| 17 | 18 | ||
| 18 | − | export function meta({ params }: Route.MetaArgs) { | |
| 19 | − | return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }]; | |
| 19 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | + | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 2 | 2 | import { Form, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pull-new"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { | |
| 6 | 7 | Button, | |
| 7 | 8 | CopyLine, | |
| 14 | 15 | import { repos, work } from "../../lib/services.server"; | |
| 15 | 16 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 16 | 17 | ||
| 17 | − | export function meta({ params }: Route.MetaArgs) { | |
| 18 | − | return [{ title: `New pull request · ${params.owner}/${params.repo} · g1t` }]; | |
| 18 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 19 | + | return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` }); | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | export async function loader({ request, params, context }: Route.LoaderArgs) { |
| 36 | 36 | } from "@g1t/contracts"; | |
| 37 | 37 | ||
| 38 | 38 | import type { Route } from "./+types/pull"; | |
| 39 | + | import { excerpt, page } from "../../lib/meta"; | |
| 39 | 40 | import { DiffView } from "../../components/diff-view"; | |
| 40 | 41 | import { LifecyclePanel } from "../../components/lifecycle"; | |
| 41 | 42 | import { Markdown } from "../../components/markdown"; | |
| 68 | 69 | const TABS = ["conversation", "session", "changes"] as const; | |
| 69 | 70 | type Tab = (typeof TABS)[number]; | |
| 70 | 71 | ||
| 71 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 72 | − | const title = loaderData | |
| 73 | − | ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · ` | |
| 74 | − | : ""; | |
| 75 | − | return [{ title: `${title}${params.owner}/${params.repo} · g1t` }]; | |
| 72 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 73 | + | const pull = loaderData?.pull; | |
| 74 | + | const title = pull ? `${pull.title} · Pull request #${pull.number} · ` : ""; | |
| 75 | + | const state = { draft: "Draft", open: "Open", merged: "Merged", closed: "Closed" }[pull?.status ?? "open"]; | |
| 76 | + | const body = excerpt(pull?.body); | |
| 77 | + | return page(args, { | |
| 78 | + | title: `${title}${params.owner}/${params.repo} · g1t`, | |
| 79 | + | description: pull | |
| 80 | + | ? `${state} pull request #${pull.number} on ${params.owner}/${params.repo} by ${pull.author.username}.${body ? ` ${body}` : ""}` | |
| 81 | + | : null, | |
| 82 | + | // The card shows the title and the state. | |
| 83 | + | version: pull ? [pull.title, pull.status] : undefined, | |
| 84 | + | type: "article", | |
| 85 | + | }); | |
| 76 | 86 | } | |
| 77 | 87 | ||
| 78 | 88 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pulls"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | 7 | import { CheckBadge } from "../../components/checks"; | |
| 7 | 8 | import { ChangeSize, PullIcon, StateTabs } from "../../components/work"; | |
| 8 | 9 | import { work } from "../../lib/services.server"; | |
| 9 | 10 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 10 | 11 | ||
| 11 | − | export function meta({ params }: Route.MetaArgs) { | |
| 12 | − | return [{ title: `Pull requests · ${params.owner}/${params.repo} · g1t` }]; | |
| 12 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | + | return page(args, { title: `Pull requests · ${params.owner}/${params.repo} · g1t` }); | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 14 | 14 | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 15 | 15 | ||
| 16 | 16 | import type { Route } from "./+types/queue"; | |
| 17 | + | import { page } from "../../lib/meta"; | |
| 17 | 18 | import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 18 | 19 | import { repos, work } from "../../lib/services.server"; | |
| 19 | 20 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 20 | 21 | ||
| 21 | 22 | const REFRESH_MS = 4000; | |
| 22 | 23 | ||
| 23 | − | export function meta({ params }: Route.MetaArgs) { | |
| 24 | − | return [{ title: `Merge queue · ${params.owner}/${params.repo} · g1t` }]; | |
| 24 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 25 | + | return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` }); | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/secrets"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 3 | 4 | import { SecretsPanel } from "../../components/secrets"; | |
| 4 | 5 | import { actOnSecrets, loadSecrets } from "../../lib/secrets.server"; | |
| 5 | 6 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 6 | 7 | ||
| 7 | − | export function meta({ params }: Route.MetaArgs) { | |
| 8 | − | return [{ title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` }]; | |
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | + | return page(args, { title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` }); | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | const ownerOf = (params: { owner: string; repo: string }) => ({ repo: { namespace: params.owner, name: params.repo } }); |
| 4 | 4 | import type { DependencyLink } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/settings-dependencies"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 7 | 8 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 8 | 9 | import { Button, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 9 | 10 | import { projects } from "../../lib/services.server"; | |
| 10 | 11 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 11 | 12 | ||
| 12 | − | export function meta({ params }: Route.MetaArgs) { | |
| 13 | − | return [{ title: `Dependencies · ${params.owner}/${params.repo} · g1t` }]; | |
| 13 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | + | return page(args, { title: `Dependencies · ${params.owner}/${params.repo} · g1t` }); | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/settings-deployments"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 4 | 5 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 6 | 7 | import { deployments } from "../../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({ params }: Route.MetaArgs) { | |
| 10 | − | return [{ title: `Deployment settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 10 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: `Deployment settings · ${params.owner}/${params.repo} · g1t` }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 3 | 3 | ||
| 4 | 4 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | 5 | import type { Route } from "./+types/settings-repository"; | |
| 6 | + | import { page } from "../../lib/meta"; | |
| 6 | 7 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 7 | 8 | import { repos, work } from "../../lib/services.server"; | |
| 8 | 9 | import { | |
| 13 | 14 | unwrap, | |
| 14 | 15 | } from "../../lib/session.server"; | |
| 15 | 16 | ||
| 16 | − | export function meta({ params }: Route.MetaArgs) { | |
| 17 | − | return [{ title: `Repository settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 17 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | + | return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` }); | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 2 | 2 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 6 | 7 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 7 | 8 | import { projects } from "../../lib/services.server"; | |
| 8 | 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 9 | 10 | ||
| 10 | − | export function meta({ params }: Route.MetaArgs) { | |
| 11 | − | return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 11 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` }); | |
| 12 | 13 | } | |
| 13 | 14 | ||
| 14 | 15 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 2 | 2 | import { Link, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/soon"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { ROADMAP, roadmapIn, roadmapItem } from "../../lib/roadmap"; | |
| 6 | 7 | ||
| 7 | − | export function meta({ params }: Route.MetaArgs) { | |
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 8 | 9 | const item = roadmapItem(params.feature); | |
| 9 | − | return [{ title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t` }]; | |
| 10 | + | return page(args, { | |
| 11 | + | title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t`, | |
| 12 | + | description: item ? `Soon on g1t: ${item.summary}` : null, | |
| 13 | + | version: item ? [item.title, item.summary] : undefined, | |
| 14 | + | }); | |
| 10 | 15 | } | |
| 11 | 16 | ||
| 12 | 17 | export function loader({ params }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/tree"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { TreeView } from "../../components/repo-view"; | |
| 3 | 4 | import { repos } from "../../lib/services.server"; | |
| 4 | 5 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 5 | 6 | ||
| 6 | − | export function meta({ params }: Route.MetaArgs) { | |
| 7 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 7 | 8 | const path = params["*"] ? `${params["*"]} · ` : ""; | |
| 8 | − | return [{ title: `${path}${params.owner}/${params.repo} · g1t` }]; | |
| 9 | + | return page(args, { title: `${path}${params.owner}/${params.repo} · g1t` }); | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/webhooks"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 3 | 4 | import { WebhooksPanel } from "../../components/webhooks"; | |
| 4 | 5 | import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server"; | |
| 5 | 6 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 6 | 7 | ||
| 7 | − | export function meta({ params }: Route.MetaArgs) { | |
| 8 | − | return [{ title: `Webhooks · ${params.owner}/${params.repo} · g1t` }]; | |
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | + | return page(args, { title: `Webhooks · ${params.owner}/${params.repo} · g1t` }); | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | function ownerOf(params: { owner: string; repo: string }) { |
| 1 | 1 | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/reset"; | |
| 4 | + | import { page } from "../lib/meta"; | |
| 4 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin } from "../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({}: Route.MetaArgs) { | |
| 10 | − | return [{ title: "Choose a new password · g1t" }]; | |
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: "Choose a new password · g1t" }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | export function loader({ request }: Route.LoaderArgs) { |
| 2 | 2 | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | + | import { page } from "../lib/meta"; | |
| 5 | 6 | import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui"; | |
| 6 | 7 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 7 | 8 | ||
| 8 | − | export function meta({}: Route.MetaArgs) { | |
| 9 | − | return [{ title: "Settings · g1t" }]; | |
| 9 | + | export function meta(args: Route.MetaArgs) { | |
| 10 | + | return page(args, { title: "Settings · g1t" }); | |
| 10 | 11 | } | |
| 11 | 12 | ||
| 12 | 13 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 2 | 2 | import { redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/verify"; | |
| 5 | + | import { page } from "../lib/meta"; | |
| 5 | 6 | import { ButtonLink } from "../components/ui"; | |
| 6 | 7 | import { identity } from "../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({}: Route.MetaArgs) { | |
| 10 | − | return [{ title: "Confirm your email · g1t" }]; | |
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: "Confirm your email · g1t" }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | /** Follows the link from the confirmation email. */ |
| 12 | 12 | } from "@g1t/contracts"; | |
| 13 | 13 | ||
| 14 | 14 | import type { Route } from "./+types/billing"; | |
| 15 | + | import { page } from "../../lib/meta"; | |
| 15 | 16 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 16 | 17 | import { billing, deployments } from "../../lib/services.server"; | |
| 17 | 18 | import { | |
| 25 | 26 | /** What can be added in one payment, in dollars. */ | |
| 26 | 27 | const AMOUNTS = [10, 25, 50, 100]; | |
| 27 | 28 | ||
| 28 | − | export function meta({ params }: Route.MetaArgs) { | |
| 29 | − | return [{ title: `Billing · ${params.owner} · g1t` }]; | |
| 29 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 30 | + | return page(args, { title: `Billing · ${params.owner} · g1t` }); | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 23 | 23 | } from "@g1t/contracts"; | |
| 24 | 24 | ||
| 25 | 25 | import type { Route } from "./+types/integrations"; | |
| 26 | + | import { page } from "../../lib/meta"; | |
| 26 | 27 | import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers"; | |
| 27 | 28 | import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 28 | 29 | import { billing, integrations, repos } from "../../lib/services.server"; | |
| 29 | 30 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 30 | 31 | ||
| 31 | − | export function meta({ params }: Route.MetaArgs) { | |
| 32 | − | return [{ title: `Integrations · ${params.owner} · g1t` }]; | |
| 32 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 33 | + | return page(args, { title: `Integrations · ${params.owner} · g1t` }); | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS; |
| 2 | 2 | import { Outlet, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/layout"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui"; | |
| 6 | 7 | import { identity } from "../../lib/services.server"; | |
| 7 | 8 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 8 | 9 | ||
| 9 | − | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 10 | − | return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }]; | |
| 10 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import { Form, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/new"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 4 | 5 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 5 | 6 | import { identity } from "../../lib/services.server"; | |
| 6 | 7 | import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server"; | |
| 7 | 8 | ||
| 8 | − | export function meta({}: Route.MetaArgs) { | |
| 9 | − | return [{ title: "New workspace · g1t" }]; | |
| 9 | + | export function meta(args: Route.MetaArgs) { | |
| 10 | + | return page(args, { title: "New workspace · g1t" }); | |
| 10 | 11 | } | |
| 11 | 12 | ||
| 12 | 13 | export function loader({ request, context }: Route.LoaderArgs) { |
| 1 | 1 | import { Form } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/people"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 4 | 5 | import { Avatar, Button, ErrorText, Field, Input, Pill } from "../../components/ui"; | |
| 5 | 6 | import { identity } from "../../lib/services.server"; | |
| 6 | 7 | import { | |
| 11 | 12 | unwrap, | |
| 12 | 13 | } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | − | export function meta({ params }: Route.MetaArgs) { | |
| 15 | − | return [{ title: `People · ${params.owner} · g1t` }]; | |
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `People · ${params.owner} · g1t` }); | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/secrets"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { SecretsPanel } from "../../components/secrets"; | |
| 3 | 4 | import { actOnSecrets, loadSecrets } from "../../lib/secrets.server"; | |
| 4 | 5 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 5 | 6 | ||
| 6 | − | export function meta({ params }: Route.MetaArgs) { | |
| 7 | − | return [{ title: `Secrets and variables · ${params.owner} · g1t` }]; | |
| 7 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 8 | + | return page(args, { title: `Secrets and variables · ${params.owner} · g1t` }); | |
| 8 | 9 | } | |
| 9 | 10 | ||
| 10 | 11 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 1 | 1 | import { Form, data, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/settings"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 4 | 5 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 5 | 6 | import { identity } from "../../lib/services.server"; | |
| 6 | 7 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 7 | 8 | ||
| 8 | − | export function meta({ params }: Route.MetaArgs) { | |
| 9 | − | return [{ title: `Settings · ${params.owner} · g1t` }]; | |
| 9 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 10 | + | return page(args, { title: `Settings · ${params.owner} · g1t` }); | |
| 10 | 11 | } | |
| 11 | 12 | ||
| 12 | 13 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 1 | 1 | import { Form } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/tokens"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 4 | 5 | import { | |
| 5 | 6 | Button, | |
| 6 | 7 | CopyLine, | |
| 19 | 20 | unwrap, | |
| 20 | 21 | } from "../../lib/session.server"; | |
| 21 | 22 | ||
| 22 | − | export function meta({ params }: Route.MetaArgs) { | |
| 23 | − | return [{ title: `Access tokens · ${params.owner} · g1t` }]; | |
| 23 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 24 | + | return page(args, { title: `Access tokens · ${params.owner} · g1t` }); | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 4 | 4 | import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/usage"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 7 | 8 | import { ButtonLink } from "../../components/ui"; | |
| 8 | 9 | import { billing } from "../../lib/services.server"; | |
| 9 | 10 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 34 | 35 | return new Date(day.getTime() - (days - 1) * 86_400_000); | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | − | export function meta({ params }: Route.MetaArgs) { | |
| 38 | − | return [{ title: `Usage · ${params.owner} · g1t` }]; | |
| 38 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 39 | + | return page(args, { title: `Usage · ${params.owner} · g1t` }); | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 1 | 1 | import type { Route } from "./+types/webhooks"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 2 | 3 | import { WebhooksPanel } from "../../components/webhooks"; | |
| 3 | 4 | import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server"; | |
| 4 | 5 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 5 | 6 | ||
| 6 | − | export function meta({ params }: Route.MetaArgs) { | |
| 7 | − | return [{ title: `Webhooks · ${params.owner} · g1t` }]; | |
| 7 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 8 | + | return page(args, { title: `Webhooks · ${params.owner} · g1t` }); | |
| 8 | 9 | } | |
| 9 | 10 | ||
| 10 | 11 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
Binary or large file; its contents are not shown.
| 1046 | 1046 | | `services/billing` | Rust | Worker + D1 + Stripe | Each workspace's agent credit: payments, the ledger of every run, and the gate on starting one | | |
| 1047 | 1047 | | `services/events` | Rust | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service | | |
| 1048 | 1048 | | `services/runner`, `crates/runner` | TypeScript, Rust | Worker + Containers | Starts a sandbox per g1t agent; the program inside runs the agent harness and reports through the public API | | |
| 1049 | + | | `services/og` | TypeScript | Worker + Cache API | Social cards at `og.g1t.sh`: one PNG per page of the site and the docs (satori and resvg), looked up as an anonymous visitor, so nothing private appears on one | | |
| 1049 | 1050 | | `apps/web` | TypeScript | Worker | Server-rendered site. Holds no data; calls services over RPC. | | |
| 1050 | 1051 | | `apps/docs` | TypeScript | Worker (static) | Documentation and the API explorer | | |
| 1051 | 1052 | | `apps/api` | Rust | Worker | REST API (`api.g1t.sh`), MCP server (`mcp.g1t.sh`) and OpenAPI document, all generated from one list of operations; the OAuth endpoints | |
| 1617 | 1617 | "resolved": "services/models", | |
| 1618 | 1618 | "link": true | |
| 1619 | 1619 | }, | |
| 1620 | + | "node_modules/@g1t/og": { | |
| 1621 | + | "resolved": "services/og", | |
| 1622 | + | "link": true | |
| 1623 | + | }, | |
| 1620 | 1624 | "node_modules/@g1t/pages": { | |
| 1621 | 1625 | "resolved": "services/pages", | |
| 1622 | 1626 | "link": true | |
| 4017 | 4021 | "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", | |
| 4018 | 4022 | "license": "MIT" | |
| 4019 | 4023 | }, | |
| 4024 | + | "node_modules/@resvg/resvg-wasm": { | |
| 4025 | + | "version": "2.6.2", | |
| 4026 | + | "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz", | |
| 4027 | + | "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==", | |
| 4028 | + | "license": "MPL-2.0", | |
| 4029 | + | "engines": { | |
| 4030 | + | "node": ">= 10" | |
| 4031 | + | } | |
| 4032 | + | }, | |
| 4020 | 4033 | "node_modules/@rolldown/binding-android-arm-eabi": { | |
| 4021 | 4034 | "version": "1.2.12", | |
| 4022 | 4035 | "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", | |
| 4381 | 4394 | "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", | |
| 4382 | 4395 | "license": "MIT" | |
| 4383 | 4396 | }, | |
| 4397 | + | "node_modules/@shuding/opentype.js": { | |
| 4398 | + | "version": "1.4.0-beta.0", | |
| 4399 | + | "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", | |
| 4400 | + | "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", | |
| 4401 | + | "license": "MIT", | |
| 4402 | + | "dependencies": { | |
| 4403 | + | "fflate": "^0.7.3", | |
| 4404 | + | "string.prototype.codepointat": "^0.2.1" | |
| 4405 | + | }, | |
| 4406 | + | "bin": { | |
| 4407 | + | "ot": "bin/ot" | |
| 4408 | + | }, | |
| 4409 | + | "engines": { | |
| 4410 | + | "node": ">= 8.0.0" | |
| 4411 | + | } | |
| 4412 | + | }, | |
| 4384 | 4413 | "node_modules/@sindresorhus/is": { | |
| 4385 | 4414 | "version": "7.2.0", | |
| 4386 | 4415 | "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", | |
| 5028 | 5057 | "url": "https://github.com/sponsors/wooorm" | |
| 5029 | 5058 | } | |
| 5030 | 5059 | }, | |
| 5060 | + | "node_modules/base64-js": { | |
| 5061 | + | "version": "0.0.8", | |
| 5062 | + | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", | |
| 5063 | + | "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", | |
| 5064 | + | "license": "MIT", | |
| 5065 | + | "engines": { | |
| 5066 | + | "node": ">= 0.4" | |
| 5067 | + | } | |
| 5068 | + | }, | |
| 5031 | 5069 | "node_modules/baseline-browser-mapping": { | |
| 5032 | 5070 | "version": "2.11.27", | |
| 5033 | 5071 | "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", | |
| 5113 | 5151 | "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | |
| 5114 | 5152 | } | |
| 5115 | 5153 | }, | |
| 5154 | + | "node_modules/camelize": { | |
| 5155 | + | "version": "1.0.1", | |
| 5156 | + | "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", | |
| 5157 | + | "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", | |
| 5158 | + | "license": "MIT", | |
| 5159 | + | "funding": { | |
| 5160 | + | "url": "https://github.com/sponsors/ljharb" | |
| 5161 | + | } | |
| 5162 | + | }, | |
| 5116 | 5163 | "node_modules/caniuse-lite": { | |
| 5117 | 5164 | "version": "1.0.30001814", | |
| 5118 | 5165 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", | |
| 5235 | 5282 | "node": ">=6" | |
| 5236 | 5283 | } | |
| 5237 | 5284 | }, | |
| 5285 | + | "node_modules/color-name": { | |
| 5286 | + | "version": "1.1.4", | |
| 5287 | + | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", | |
| 5288 | + | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", | |
| 5289 | + | "license": "MIT" | |
| 5290 | + | }, | |
| 5238 | 5291 | "node_modules/comma-separated-tokens": { | |
| 5239 | 5292 | "version": "2.0.3", | |
| 5240 | 5293 | "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", | |
| 5306 | 5359 | "uncrypto": "^0.1.3" | |
| 5307 | 5360 | } | |
| 5308 | 5361 | }, | |
| 5362 | + | "node_modules/css-background-parser": { | |
| 5363 | + | "version": "0.1.0", | |
| 5364 | + | "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", | |
| 5365 | + | "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", | |
| 5366 | + | "license": "MIT" | |
| 5367 | + | }, | |
| 5368 | + | "node_modules/css-box-shadow": { | |
| 5369 | + | "version": "1.0.0-3", | |
| 5370 | + | "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", | |
| 5371 | + | "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", | |
| 5372 | + | "license": "MIT" | |
| 5373 | + | }, | |
| 5374 | + | "node_modules/css-color-keywords": { | |
| 5375 | + | "version": "1.0.0", | |
| 5376 | + | "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", | |
| 5377 | + | "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", | |
| 5378 | + | "license": "ISC", | |
| 5379 | + | "engines": { | |
| 5380 | + | "node": ">=4" | |
| 5381 | + | } | |
| 5382 | + | }, | |
| 5383 | + | "node_modules/css-gradient-parser": { | |
| 5384 | + | "version": "0.0.17", | |
| 5385 | + | "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", | |
| 5386 | + | "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", | |
| 5387 | + | "license": "MIT", | |
| 5388 | + | "engines": { | |
| 5389 | + | "node": ">=16" | |
| 5390 | + | } | |
| 5391 | + | }, | |
| 5309 | 5392 | "node_modules/css-select": { | |
| 5310 | 5393 | "version": "6.0.0", | |
| 5311 | 5394 | "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", | |
| 5338 | 5421 | ], | |
| 5339 | 5422 | "license": "MIT" | |
| 5340 | 5423 | }, | |
| 5424 | + | "node_modules/css-to-react-native": { | |
| 5425 | + | "version": "3.2.0", | |
| 5426 | + | "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", | |
| 5427 | + | "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", | |
| 5428 | + | "license": "MIT", | |
| 5429 | + | "dependencies": { | |
| 5430 | + | "camelize": "^1.0.0", | |
| 5431 | + | "css-color-keywords": "^1.0.0", | |
| 5432 | + | "postcss-value-parser": "^4.0.2" | |
| 5433 | + | } | |
| 5434 | + | }, | |
| 5341 | 5435 | "node_modules/css-tree": { | |
| 5342 | 5436 | "version": "3.2.1", | |
| 5343 | 5437 | "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", | |
| 5607 | 5701 | "dev": true, | |
| 5608 | 5702 | "license": "ISC" | |
| 5609 | 5703 | }, | |
| 5704 | + | "node_modules/emoji-regex-xs": { | |
| 5705 | + | "version": "2.0.1", | |
| 5706 | + | "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", | |
| 5707 | + | "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", | |
| 5708 | + | "license": "MIT", | |
| 5709 | + | "engines": { | |
| 5710 | + | "node": ">=10.0.0" | |
| 5711 | + | } | |
| 5712 | + | }, | |
| 5610 | 5713 | "node_modules/enhanced-resolve": { | |
| 5611 | 5714 | "version": "5.26.0", | |
| 5612 | 5715 | "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", | |
| 5700 | 5803 | "node": ">=6" | |
| 5701 | 5804 | } | |
| 5702 | 5805 | }, | |
| 5806 | + | "node_modules/escape-html": { | |
| 5807 | + | "version": "1.0.3", | |
| 5808 | + | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", | |
| 5809 | + | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", | |
| 5810 | + | "license": "MIT" | |
| 5811 | + | }, | |
| 5703 | 5812 | "node_modules/escape-string-regexp": { | |
| 5704 | 5813 | "version": "5.0.0", | |
| 5705 | 5814 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", | |
| 5807 | 5916 | } | |
| 5808 | 5917 | } | |
| 5809 | 5918 | }, | |
| 5919 | + | "node_modules/fflate": { | |
| 5920 | + | "version": "0.7.3", | |
| 5921 | + | "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz", | |
| 5922 | + | "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==", | |
| 5923 | + | "license": "MIT" | |
| 5924 | + | }, | |
| 5810 | 5925 | "node_modules/find-proc": { | |
| 5811 | 5926 | "version": "0.2.0", | |
| 5812 | 5927 | "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.2.0.tgz", | |
| 6264 | 6379 | "url": "https://opencollective.com/unified" | |
| 6265 | 6380 | } | |
| 6266 | 6381 | }, | |
| 6382 | + | "node_modules/hex-rgb": { | |
| 6383 | + | "version": "4.3.0", | |
| 6384 | + | "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", | |
| 6385 | + | "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", | |
| 6386 | + | "license": "MIT", | |
| 6387 | + | "engines": { | |
| 6388 | + | "node": ">=6" | |
| 6389 | + | }, | |
| 6390 | + | "funding": { | |
| 6391 | + | "url": "https://github.com/sponsors/sindresorhus" | |
| 6392 | + | } | |
| 6393 | + | }, | |
| 6267 | 6394 | "node_modules/html-escaper": { | |
| 6268 | 6395 | "version": "3.0.3", | |
| 6269 | 6396 | "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", | |
| 6792 | 6919 | "url": "https://opencollective.com/parcel" | |
| 6793 | 6920 | } | |
| 6794 | 6921 | }, | |
| 6922 | + | "node_modules/linebreak": { | |
| 6923 | + | "version": "1.1.0", | |
| 6924 | + | "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", | |
| 6925 | + | "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", | |
| 6926 | + | "license": "MIT", | |
| 6927 | + | "dependencies": { | |
| 6928 | + | "base64-js": "0.0.8", | |
| 6929 | + | "unicode-trie": "^2.0.0" | |
| 6930 | + | } | |
| 6931 | + | }, | |
| 6795 | 6932 | "node_modules/lodash": { | |
| 6796 | 6933 | "version": "4.18.1", | |
| 6797 | 6934 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", | |
| 8010 | 8147 | "@pagefind/windows-x64": "1.5.2" | |
| 8011 | 8148 | } | |
| 8012 | 8149 | }, | |
| 8150 | + | "node_modules/pako": { | |
| 8151 | + | "version": "0.2.9", | |
| 8152 | + | "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", | |
| 8153 | + | "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", | |
| 8154 | + | "license": "MIT" | |
| 8155 | + | }, | |
| 8156 | + | "node_modules/parse-css-color": { | |
| 8157 | + | "version": "0.2.1", | |
| 8158 | + | "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", | |
| 8159 | + | "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", | |
| 8160 | + | "license": "MIT", | |
| 8161 | + | "dependencies": { | |
| 8162 | + | "color-name": "^1.1.4", | |
| 8163 | + | "hex-rgb": "^4.1.0" | |
| 8164 | + | } | |
| 8165 | + | }, | |
| 8013 | 8166 | "node_modules/parse-entities": { | |
| 8014 | 8167 | "version": "4.0.2", | |
| 8015 | 8168 | "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", | |
| 8175 | 8328 | "node": ">=4" | |
| 8176 | 8329 | } | |
| 8177 | 8330 | }, | |
| 8331 | + | "node_modules/postcss-value-parser": { | |
| 8332 | + | "version": "4.2.0", | |
| 8333 | + | "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", | |
| 8334 | + | "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", | |
| 8335 | + | "license": "MIT" | |
| 8336 | + | }, | |
| 8178 | 8337 | "node_modules/prismjs": { | |
| 8179 | 8338 | "version": "1.30.0", | |
| 8180 | 8339 | "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", | |
| 8838 | 8997 | "url": "https://github.com/sponsors/wooorm" | |
| 8839 | 8998 | } | |
| 8840 | 8999 | }, | |
| 9000 | + | "node_modules/string.prototype.codepointat": { | |
| 9001 | + | "version": "0.2.1", | |
| 9002 | + | "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", | |
| 9003 | + | "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", | |
| 9004 | + | "license": "MIT" | |
| 9005 | + | }, | |
| 8841 | 9006 | "node_modules/stringify-entities": { | |
| 8842 | 9007 | "version": "4.0.4", | |
| 8843 | 9008 | "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", | |
| 9063 | 9228 | "pathe": "^2.0.3" | |
| 9064 | 9229 | } | |
| 9065 | 9230 | }, | |
| 9231 | + | "node_modules/unicode-trie": { | |
| 9232 | + | "version": "2.0.0", | |
| 9233 | + | "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", | |
| 9234 | + | "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", | |
| 9235 | + | "license": "MIT", | |
| 9236 | + | "dependencies": { | |
| 9237 | + | "pako": "^0.2.5", | |
| 9238 | + | "tiny-inflate": "^1.0.0" | |
| 9239 | + | } | |
| 9240 | + | }, | |
| 9066 | 9241 | "node_modules/unified": { | |
| 9067 | 9242 | "version": "11.0.5", | |
| 9068 | 9243 | "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", | |
| 9945 | 10120 | "url": "https://github.com/sponsors/sindresorhus" | |
| 9946 | 10121 | } | |
| 9947 | 10122 | }, | |
| 10123 | + | "node_modules/yoga-layout": { | |
| 10124 | + | "version": "3.2.1", | |
| 10125 | + | "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", | |
| 10126 | + | "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", | |
| 10127 | + | "license": "MIT" | |
| 10128 | + | }, | |
| 9948 | 10129 | "node_modules/youch": { | |
| 9949 | 10130 | "version": "4.1.0-beta.10", | |
| 9950 | 10131 | "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", | |
| 10015 | 10196 | "@g1t/contracts": "*" | |
| 10016 | 10197 | } | |
| 10017 | 10198 | }, | |
| 10199 | + | "services/og": { | |
| 10200 | + | "name": "@g1t/og", | |
| 10201 | + | "version": "0.1.0", | |
| 10202 | + | "license": "MIT", | |
| 10203 | + | "dependencies": { | |
| 10204 | + | "@g1t/contracts": "*", | |
| 10205 | + | "@resvg/resvg-wasm": "^2.6.2", | |
| 10206 | + | "satori": "0.32.0" | |
| 10207 | + | } | |
| 10208 | + | }, | |
| 10209 | + | "services/og/node_modules/satori": { | |
| 10210 | + | "version": "0.32.0", | |
| 10211 | + | "resolved": "https://registry.npmjs.org/satori/-/satori-0.32.0.tgz", | |
| 10212 | + | "integrity": "sha512-WwYeCWKjxfXvk7SsLSLTJhcPOrB2HCxbptqGIiYor0yqaPu3xA5uMTQ/4Dan5mxClhhk2Wn9ZE1pLGIlGdUxFQ==", | |
| 10213 | + | "license": "MPL-2.0", | |
| 10214 | + | "dependencies": { | |
| 10215 | + | "@shuding/opentype.js": "1.4.0-beta.0", | |
| 10216 | + | "css-background-parser": "^0.1.0", | |
| 10217 | + | "css-box-shadow": "1.0.0-3", | |
| 10218 | + | "css-gradient-parser": "^0.0.17", | |
| 10219 | + | "css-to-react-native": "^3.0.0", | |
| 10220 | + | "emoji-regex-xs": "^2.0.1", | |
| 10221 | + | "escape-html": "^1.0.3", | |
| 10222 | + | "linebreak": "^1.1.0", | |
| 10223 | + | "parse-css-color": "^0.2.1", | |
| 10224 | + | "postcss-value-parser": "^4.2.0", | |
| 10225 | + | "yoga-layout": "^3.2.1" | |
| 10226 | + | }, | |
| 10227 | + | "engines": { | |
| 10228 | + | "node": ">=16" | |
| 10229 | + | } | |
| 10230 | + | }, | |
| 10018 | 10231 | "services/pages": { | |
| 10019 | 10232 | "name": "@g1t/pages", | |
| 10020 | 10233 | "version": "0.1.0", |
| 29 | 29 | services/runner | |
| 30 | 30 | apps/api | |
| 31 | 31 | services/pages | |
| 32 | + | services/og | |
| 32 | 33 | apps/web | |
| 33 | 34 | apps/sudo | |
| 34 | 35 | apps/docs |
| 1 | + | { | |
| 2 | + | "name": "@g1t/og", | |
| 3 | + | "version": "0.1.0", | |
| 4 | + | "private": true, | |
| 5 | + | "type": "module", | |
| 6 | + | "license": "MIT", | |
| 7 | + | "scripts": { | |
| 8 | + | "test": "node --test src/*.test.ts", | |
| 9 | + | "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json", | |
| 10 | + | "deploy": "wrangler deploy" | |
| 11 | + | }, | |
| 12 | + | "dependencies": { | |
| 13 | + | "@g1t/contracts": "*", | |
| 14 | + | "@resvg/resvg-wasm": "^2.6.2", | |
| 15 | + | "satori": "0.32.0" | |
| 16 | + | } | |
| 17 | + | } |
| 1 | + | /** | |
| 2 | + | * The brand, as SVG for satori: the Fleet mark and the "g1t" wordmark, the | |
| 3 | + | * same shapes as `.g1t/brand/svg/logo.svg`. The wordmark is outlined, so | |
| 4 | + | * the card needs no ExtraBold font. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | export const COLOR = { | |
| 8 | + | bg: "#0f0f11", | |
| 9 | + | surface: "#161618", | |
| 10 | + | raised: "#1e1e21", | |
| 11 | + | line: "#28282c", | |
| 12 | + | lineStrong: "#38383e", | |
| 13 | + | fg: "#ededef", | |
| 14 | + | fgSoft: "#dcdce0", | |
| 15 | + | muted: "#a0a0a8", | |
| 16 | + | faint: "#6a6a72", | |
| 17 | + | /** Lavender: the accent. */ | |
| 18 | + | lavender: "#b6a8ff", | |
| 19 | + | lavenderDeep: "#6b56e8", | |
| 20 | + | /** Status colours, from the site's tokens. */ | |
| 21 | + | mint: "#86efc4", | |
| 22 | + | danger: "#ff8394", | |
| 23 | + | } as const; | |
| 24 | + | ||
| 25 | + | /** The mark's shapes, in its 32-unit box. */ | |
| 26 | + | function markShapes(front: string, behind: string): string { | |
| 27 | + | return ( | |
| 28 | + | `<g transform="translate(0.7 0.5)">` + | |
| 29 | + | `<rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="${behind}" fill-opacity="0.35"/>` + | |
| 30 | + | `<rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="${behind}" fill-opacity="0.65"/>` + | |
| 31 | + | `<rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="${front}"/>` + | |
| 32 | + | `<path d="M21.7 7.2 17.6 10.9" fill="none" stroke="${front}" stroke-width="4.6" stroke-linecap="round"/>` + | |
| 33 | + | `</g>` | |
| 34 | + | ); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const WORDMARK = | |
| 38 | + | "M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z"; | |
| 39 | + | ||
| 40 | + | /** | |
| 41 | + | * The lockup: the mark as tall as the wordmark's capitals, standing on its | |
| 42 | + | * baseline. Its box is 233 by 95; the baseline is at 72.75. | |
| 43 | + | */ | |
| 44 | + | export const LOCKUP_BOX = { width: 233, height: 95, baseline: 72.75 } as const; | |
| 45 | + | ||
| 46 | + | export function lockupSvg(front: string = COLOR.fg, behind: string = COLOR.lavender): string { | |
| 47 | + | return ( | |
| 48 | + | `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${LOCKUP_BOX.width} ${LOCKUP_BOX.height}">` + | |
| 49 | + | `<g transform="translate(-22.77 -16.35) scale(3.3)">${markShapes(front, behind)}</g>` + | |
| 50 | + | `<path transform="translate(101.08 72.75)" fill="${front}" d="${WORDMARK}"/>` + | |
| 51 | + | `</svg>` | |
| 52 | + | ); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** The mark alone, cropped to the figure: 18.2 by 22. */ | |
| 56 | + | export function markSvg(front: string = COLOR.fg, behind: string = COLOR.lavender): string { | |
| 57 | + | return ( | |
| 58 | + | `<svg xmlns="http://www.w3.org/2000/svg" viewBox="6.9 5 18.2 22">${markShapes(front, behind)}</svg>` | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** As an image source; base64, which both satori and resvg read. The SVG is ASCII. */ | |
| 63 | + | /** | |
| 64 | + | * The card's backdrop: the brand card's dot grid, lavender light from the | |
| 65 | + | * top right, and the fleet standing large off the right edge, its 1s | |
| 66 | + | * fading downwards into the dark. Darker towards the bottom, where the | |
| 67 | + | * facts sit. | |
| 68 | + | */ | |
| 69 | + | export function backdropSvg(width: number, height: number, withFleet: boolean): string { | |
| 70 | + | // The fleet at 24 times its 32-unit size, its front 1 cut by the edge. | |
| 71 | + | const scale = 24; | |
| 72 | + | // Drawn solid and faded as a whole, so the flag and the bar it joins | |
| 73 | + | // read as one shape. | |
| 74 | + | const fleet = withFleet | |
| 75 | + | ? `<g mask="url(#fleetFade)" opacity="0.34">` + | |
| 76 | + | `<g transform="translate(${width - 22.4 * scale} ${height - 27.5 * scale + 40}) scale(${scale})">` + | |
| 77 | + | markShapes(COLOR.lavender, COLOR.lavender) + | |
| 78 | + | `</g></g>` | |
| 79 | + | : ""; | |
| 80 | + | return ( | |
| 81 | + | `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` + | |
| 82 | + | `<defs>` + | |
| 83 | + | `<pattern id="dots" width="40" height="40" patternUnits="userSpaceOnUse">` + | |
| 84 | + | `<circle cx="20" cy="15" r="1.4" fill="#2a2a30"/></pattern>` + | |
| 85 | + | `<radialGradient id="light" cx="${width - 160}" cy="-40" r="760" gradientUnits="userSpaceOnUse">` + | |
| 86 | + | `<stop offset="0" stop-color="${COLOR.lavenderDeep}" stop-opacity="0.30"/>` + | |
| 87 | + | `<stop offset="0.45" stop-color="${COLOR.lavenderDeep}" stop-opacity="0.09"/>` + | |
| 88 | + | `<stop offset="1" stop-color="${COLOR.lavenderDeep}" stop-opacity="0"/></radialGradient>` + | |
| 89 | + | `<linearGradient id="dusk" x1="0" y1="0" x2="0" y2="1">` + | |
| 90 | + | `<stop offset="0.5" stop-color="${COLOR.bg}" stop-opacity="0"/>` + | |
| 91 | + | `<stop offset="1" stop-color="${COLOR.bg}" stop-opacity="0.9"/></linearGradient>` + | |
| 92 | + | `<linearGradient id="fadeMask" x1="0" y1="0" x2="0" y2="1">` + | |
| 93 | + | `<stop offset="0.1" stop-color="#fff"/>` + | |
| 94 | + | `<stop offset="0.95" stop-color="#000"/></linearGradient>` + | |
| 95 | + | `<mask id="fleetFade" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}">` + | |
| 96 | + | `<rect width="${width}" height="${height}" fill="url(#fadeMask)"/></mask>` + | |
| 97 | + | `</defs>` + | |
| 98 | + | `<rect width="${width}" height="${height}" fill="${COLOR.bg}"/>` + | |
| 99 | + | `<rect width="${width}" height="${height}" fill="url(#dots)"/>` + | |
| 100 | + | `<rect width="${width}" height="${height}" fill="url(#light)"/>` + | |
| 101 | + | fleet + | |
| 102 | + | `<rect width="${width}" height="${height}" fill="url(#dusk)"/>` + | |
| 103 | + | `</svg>` | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | export function dataUri(svg: string): string { | |
| 108 | + | return `data:image/svg+xml;base64,${btoa(svg)}`; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** Lucide's icons, as the site uses them, at 24 units. */ | |
| 112 | + | export const ICON = { | |
| 113 | + | issue: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`, | |
| 114 | + | issueDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`, | |
| 115 | + | issueNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`, | |
| 116 | + | pull: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>`, | |
| 117 | + | pullDraft: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><line x1="6" x2="6" y1="9" y2="21"/>`, | |
| 118 | + | merged: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>`, | |
| 119 | + | pullClosed: `<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/><path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>`, | |
| 120 | + | box: `<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>`, | |
| 121 | + | sparkles: `<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/>`, | |
| 122 | + | book: `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`, | |
| 123 | + | user: `<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>`, | |
| 124 | + | globe: `<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>`, | |
| 125 | + | } as const; | |
| 126 | + | ||
| 127 | + | export function iconSvg(shapes: string, color: string): string { | |
| 128 | + | return ( | |
| 129 | + | `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" ` + | |
| 130 | + | `stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes}</svg>` | |
| 131 | + | ); | |
| 132 | + | } |
| 1 | + | /** | |
| 2 | + | * Part of every cache key: bump it when the cards' design changes, so the | |
| 3 | + | * cards already cached are drawn again. | |
| 4 | + | */ | |
| 5 | + | export const DESIGN = "1"; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * The address a card is cached under: only the parameters that change it, | |
| 9 | + | * in order, so stray ones (trackers, cache busters) cannot fill the cache. | |
| 10 | + | */ | |
| 11 | + | export function cacheKey(url: URL): string { | |
| 12 | + | const wanted = | |
| 13 | + | url.pathname === "/docs" ? ["title", "section", "description"] : url.pathname === "/image" ? ["path", "v"] : []; | |
| 14 | + | const key = new URL(url.pathname, url.origin); | |
| 15 | + | key.searchParams.set("design", DESIGN); | |
| 16 | + | for (const name of wanted) { | |
| 17 | + | const value = url.searchParams.get(name); | |
| 18 | + | if (value !== null) key.searchParams.set(name, value); | |
| 19 | + | } | |
| 20 | + | return key.toString(); | |
| 21 | + | } |
| 1 | + | /** | |
| 2 | + | * The cards' layouts, as the element trees satori lays out. One frame for | |
| 3 | + | * every kind: the lockup and the address along the top, the subject in the | |
| 4 | + | * middle, the facts along the bottom, on the site's very dark gray with a | |
| 5 | + | * dot grid and lavender kept for accents. | |
| 6 | + | */ | |
| 7 | + | import { COLOR, ICON, LOCKUP_BOX, backdropSvg, dataUri, iconSvg, lockupSvg } from "./brand.ts"; | |
| 8 | + | import type { Card, IssueState } from "./resolve.ts"; | |
| 9 | + | import type { PullStatus } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | export const WIDTH = 1200; | |
| 12 | + | export const HEIGHT = 630; | |
| 13 | + | const PAD = 80; | |
| 14 | + | ||
| 15 | + | /** An element as satori reads it. */ | |
| 16 | + | export type Node = { type: string; props: Record<string, unknown> & { style?: Style; children?: Child } }; | |
| 17 | + | type Child = Node | string | (Node | string | null | false)[] | null; | |
| 18 | + | type Style = Record<string, string | number>; | |
| 19 | + | ||
| 20 | + | function h(type: string, style: Style, ...children: (Node | string | null | false)[]): Node { | |
| 21 | + | const kids = children.filter((child): child is Node | string => child !== null && child !== false); | |
| 22 | + | if (kids.length === 0) return { type, props: { style } }; | |
| 23 | + | return { type, props: { style, children: kids.length === 1 ? kids[0] : kids } }; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | function img(src: string, width: number, height: number, style: Style = {}): Node { | |
| 27 | + | return { type: "img", props: { src, width, height, style: { width, height, ...style } } }; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function icon(shapes: string, color: string, size: number): Node { | |
| 31 | + | return img(dataUri(iconSvg(shapes, color)), size, size, { flexShrink: 0 }); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const SANS = "Inter"; | |
| 35 | + | const MONO = "JetBrains Mono"; | |
| 36 | + | ||
| 37 | + | /** Long text is cut before layout, so a huge title costs nothing to lay out. */ | |
| 38 | + | function bound(text: string, max: number): string { | |
| 39 | + | const single = text.replace(/\s+/g, " ").trim(); | |
| 40 | + | return single.length > max ? `${single.slice(0, max - 1).trimEnd()}…` : single; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /** A block of text that stops after `lines`, with an ellipsis. */ | |
| 44 | + | function clamp(text: string, lines: number, style: Style): Node { | |
| 45 | + | return h("div", { display: "block", lineClamp: lines, overflow: "hidden", ...style }, text); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** Bigger type for shorter titles, so a two-word title fills the card and a long one fits. */ | |
| 49 | + | function titleSize(text: string): number { | |
| 50 | + | if (text.length <= 28) return 76; | |
| 51 | + | if (text.length <= 56) return 64; | |
| 52 | + | if (text.length <= 96) return 54; | |
| 53 | + | return 46; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // --- Pieces -------------------------------------------------------------- | |
| 57 | + | ||
| 58 | + | /** The backdrop: dot grid, lavender light and, on most cards, the fleet. */ | |
| 59 | + | function background(withFleet: boolean): Node { | |
| 60 | + | return img(dataUri(backdropSvg(WIDTH, HEIGHT, withFleet)), WIDTH, HEIGHT, { position: "absolute", top: 0, left: 0 }); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** The lavender rule along the bottom edge. */ | |
| 64 | + | function rule(): Node { | |
| 65 | + | return h("div", { | |
| 66 | + | position: "absolute", | |
| 67 | + | left: 0, | |
| 68 | + | bottom: 0, | |
| 69 | + | width: WIDTH, | |
| 70 | + | height: 6, | |
| 71 | + | backgroundImage: `linear-gradient(to right, ${COLOR.lavenderDeep}, ${COLOR.lavender} 45%, rgba(182, 168, 255, 0.15))`, | |
| 72 | + | }); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | function lockup(height: number, suffix?: string): Node { | |
| 76 | + | const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height); | |
| 77 | + | return h( | |
| 78 | + | "div", | |
| 79 | + | { display: "flex", alignItems: "flex-end" }, | |
| 80 | + | img(dataUri(lockupSvg()), width, height), | |
| 81 | + | suffix | |
| 82 | + | ? h( | |
| 83 | + | "div", | |
| 84 | + | { | |
| 85 | + | display: "flex", | |
| 86 | + | fontFamily: SANS, | |
| 87 | + | fontWeight: 500, | |
| 88 | + | fontSize: Math.round(height * 0.62), | |
| 89 | + | color: COLOR.muted, | |
| 90 | + | marginLeft: Math.round(height * 0.22), | |
| 91 | + | // Sits on the wordmark's baseline. | |
| 92 | + | marginBottom: Math.round(height * (1 - LOCKUP_BOX.baseline / LOCKUP_BOX.height) - height * 0.1), | |
| 93 | + | letterSpacing: -0.5, | |
| 94 | + | }, | |
| 95 | + | suffix, | |
| 96 | + | ) | |
| 97 | + | : null, | |
| 98 | + | ); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | function topBar(address: string, suffix?: string): Node { | |
| 102 | + | return h( | |
| 103 | + | "div", | |
| 104 | + | { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }, | |
| 105 | + | lockup(46, suffix), | |
| 106 | + | h( | |
| 107 | + | "div", | |
| 108 | + | { display: "flex", fontFamily: MONO, fontSize: 22, color: COLOR.faint, whiteSpace: "nowrap" }, | |
| 109 | + | bound(address, 46), | |
| 110 | + | ), | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | type Tone = { color: string; bg: string; border: string }; | |
| 115 | + | ||
| 116 | + | const TONE = { | |
| 117 | + | open: { color: COLOR.mint, bg: "rgba(134, 239, 196, 0.10)", border: "rgba(134, 239, 196, 0.38)" }, | |
| 118 | + | merged: { color: COLOR.lavender, bg: "rgba(182, 168, 255, 0.12)", border: "rgba(182, 168, 255, 0.42)" }, | |
| 119 | + | closed: { color: COLOR.danger, bg: "rgba(255, 131, 148, 0.10)", border: "rgba(255, 131, 148, 0.38)" }, | |
| 120 | + | quiet: { color: COLOR.muted, bg: "rgba(160, 160, 168, 0.08)", border: "rgba(160, 160, 168, 0.30)" }, | |
| 121 | + | } satisfies Record<string, Tone>; | |
| 122 | + | ||
| 123 | + | function badge(label: string, shapes: string, tone: Tone): Node { | |
| 124 | + | return h( | |
| 125 | + | "div", | |
| 126 | + | { | |
| 127 | + | display: "flex", | |
| 128 | + | alignItems: "center", | |
| 129 | + | gap: 12, | |
| 130 | + | padding: "10px 22px 10px 18px", | |
| 131 | + | borderRadius: 999, | |
| 132 | + | backgroundColor: tone.bg, | |
| 133 | + | border: `2px solid ${tone.border}`, | |
| 134 | + | color: tone.color, | |
| 135 | + | fontFamily: SANS, | |
| 136 | + | fontWeight: 600, | |
| 137 | + | fontSize: 26, | |
| 138 | + | }, | |
| 139 | + | icon(shapes, tone.color, 28), | |
| 140 | + | label, | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /** A fact along the bottom: an icon, a number or a word, and what it counts. */ | |
| 145 | + | function fact(shapes: string, strong: string, rest: string): Node { | |
| 146 | + | return h( | |
| 147 | + | "div", | |
| 148 | + | { display: "flex", alignItems: "center", gap: 12, fontFamily: SANS, fontSize: 28, color: COLOR.muted }, | |
| 149 | + | icon(shapes, COLOR.faint, 28), | |
| 150 | + | h("div", { display: "flex", color: COLOR.fg, fontWeight: 600 }, strong), | |
| 151 | + | rest ? h("div", { display: "flex" }, rest) : null, | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | function plain(text: string, mono = false): Node { | |
| 156 | + | return h( | |
| 157 | + | "div", | |
| 158 | + | { display: "flex", fontFamily: mono ? MONO : SANS, fontSize: mono ? 25 : 28, color: COLOR.muted }, | |
| 159 | + | text, | |
| 160 | + | ); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | const dot = () => h("div", { display: "flex", fontSize: 28, color: COLOR.faint }, "·"); | |
| 164 | + | ||
| 165 | + | /** The frame every card but the brand card shares. */ | |
| 166 | + | function frame(parts: { | |
| 167 | + | address: string; | |
| 168 | + | suffix?: string; | |
| 169 | + | eyebrow: Node; | |
| 170 | + | title: Node; | |
| 171 | + | description: string | null; | |
| 172 | + | facts: Node[]; | |
| 173 | + | descriptionLines?: number; | |
| 174 | + | }): Node { | |
| 175 | + | return h( | |
| 176 | + | "div", | |
| 177 | + | { | |
| 178 | + | display: "flex", | |
| 179 | + | position: "relative", | |
| 180 | + | width: WIDTH, | |
| 181 | + | height: HEIGHT, | |
| 182 | + | backgroundColor: COLOR.bg, | |
| 183 | + | overflow: "hidden", | |
| 184 | + | }, | |
| 185 | + | background(true), | |
| 186 | + | h( | |
| 187 | + | "div", | |
| 188 | + | { | |
| 189 | + | display: "flex", | |
| 190 | + | flexDirection: "column", | |
| 191 | + | position: "relative", | |
| 192 | + | width: WIDTH, | |
| 193 | + | height: HEIGHT, | |
| 194 | + | padding: `64px ${PAD}px 72px`, | |
| 195 | + | }, | |
| 196 | + | topBar(parts.address, parts.suffix), | |
| 197 | + | h( | |
| 198 | + | "div", | |
| 199 | + | { display: "flex", flexDirection: "column", flexGrow: 1, justifyContent: "center", width: 940 }, | |
| 200 | + | parts.eyebrow, | |
| 201 | + | h("div", { display: "flex", marginTop: 18 }, parts.title), | |
| 202 | + | parts.description | |
| 203 | + | ? clamp(bound(parts.description, 260), parts.descriptionLines ?? 2, { | |
| 204 | + | marginTop: 22, | |
| 205 | + | fontFamily: SANS, | |
| 206 | + | fontSize: 30, | |
| 207 | + | lineHeight: 1.4, | |
| 208 | + | color: COLOR.muted, | |
| 209 | + | }) | |
| 210 | + | : null, | |
| 211 | + | ), | |
| 212 | + | h("div", { display: "flex", alignItems: "center", gap: 24, height: 56 }, ...parts.facts), | |
| 213 | + | ), | |
| 214 | + | rule(), | |
| 215 | + | ); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | function eyebrow(text: string, mono: boolean, color: string = COLOR.lavender): Node { | |
| 219 | + | return h( | |
| 220 | + | "div", | |
| 221 | + | { | |
| 222 | + | display: "flex", | |
| 223 | + | fontFamily: mono ? MONO : SANS, | |
| 224 | + | fontWeight: 500, | |
| 225 | + | fontSize: mono ? 28 : 26, | |
| 226 | + | color, | |
| 227 | + | letterSpacing: mono ? 0 : 2, | |
| 228 | + | textTransform: mono ? "none" : "uppercase", | |
| 229 | + | }, | |
| 230 | + | bound(text, 60), | |
| 231 | + | ); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | function title(text: string, lines = 3): Node { | |
| 235 | + | const bounded = bound(text, 180); | |
| 236 | + | const size = titleSize(bounded); | |
| 237 | + | return clamp(bounded, lines, { | |
| 238 | + | fontFamily: SANS, | |
| 239 | + | fontWeight: 600, | |
| 240 | + | fontSize: size, | |
| 241 | + | lineHeight: 1.12, | |
| 242 | + | letterSpacing: size >= 64 ? -2 : -1.4, | |
| 243 | + | color: COLOR.fg, | |
| 244 | + | width: 940, | |
| 245 | + | }); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // --- Cards --------------------------------------------------------------- | |
| 249 | + | ||
| 250 | + | function brandCard(): Node { | |
| 251 | + | const height = 190; | |
| 252 | + | const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height); | |
| 253 | + | return h( | |
| 254 | + | "div", | |
| 255 | + | { | |
| 256 | + | display: "flex", | |
| 257 | + | position: "relative", | |
| 258 | + | width: WIDTH, | |
| 259 | + | height: HEIGHT, | |
| 260 | + | backgroundColor: COLOR.bg, | |
| 261 | + | overflow: "hidden", | |
| 262 | + | }, | |
| 263 | + | background(false), | |
| 264 | + | h( | |
| 265 | + | "div", | |
| 266 | + | { | |
| 267 | + | display: "flex", | |
| 268 | + | flexDirection: "column", | |
| 269 | + | alignItems: "center", | |
| 270 | + | justifyContent: "center", | |
| 271 | + | position: "relative", | |
| 272 | + | width: WIDTH, | |
| 273 | + | height: HEIGHT, | |
| 274 | + | }, | |
| 275 | + | img(dataUri(lockupSvg()), width, height), | |
| 276 | + | h( | |
| 277 | + | "div", | |
| 278 | + | { | |
| 279 | + | display: "flex", | |
| 280 | + | marginTop: 56, | |
| 281 | + | fontFamily: SANS, | |
| 282 | + | fontWeight: 500, | |
| 283 | + | fontSize: 44, | |
| 284 | + | letterSpacing: -0.8, | |
| 285 | + | color: COLOR.fgSoft, | |
| 286 | + | }, | |
| 287 | + | "A git forge for teams of agents", | |
| 288 | + | ), | |
| 289 | + | h( | |
| 290 | + | "div", | |
| 291 | + | { display: "flex", marginTop: 40, fontFamily: MONO, fontSize: 26, color: COLOR.lavender }, | |
| 292 | + | "g1t.sh", | |
| 293 | + | ), | |
| 294 | + | ), | |
| 295 | + | rule(), | |
| 296 | + | ); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | const PULL_BADGE: Record<PullStatus, [string, string, Tone]> = { | |
| 300 | + | open: ["Open", ICON.pull, TONE.open], | |
| 301 | + | draft: ["Draft", ICON.pullDraft, TONE.quiet], | |
| 302 | + | merged: ["Merged", ICON.merged, TONE.merged], | |
| 303 | + | closed: ["Closed", ICON.pullClosed, TONE.closed], | |
| 304 | + | }; | |
| 305 | + | ||
| 306 | + | const ISSUE_BADGE: Record<IssueState, [string, string, Tone]> = { | |
| 307 | + | open: ["Open", ICON.issue, TONE.open], | |
| 308 | + | completed: ["Closed", ICON.issueDone, TONE.merged], | |
| 309 | + | not_planned: ["Not planned", ICON.issueNotPlanned, TONE.quiet], | |
| 310 | + | }; | |
| 311 | + | ||
| 312 | + | function count(n: number, one: string, many: string): [string, string] { | |
| 313 | + | return [n.toLocaleString("en-US"), n === 1 ? one : many]; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | export function cardTree(card: Card): Node { | |
| 317 | + | switch (card.kind) { | |
| 318 | + | case "brand": | |
| 319 | + | return brandCard(); | |
| 320 | + | ||
| 321 | + | case "page": | |
| 322 | + | return frame({ | |
| 323 | + | address: card.address, | |
| 324 | + | eyebrow: eyebrow(card.eyebrow, false), | |
| 325 | + | title: title(card.title), | |
| 326 | + | description: card.description, | |
| 327 | + | facts: [plain("A git forge for teams of agents")], | |
| 328 | + | }); | |
| 329 | + | ||
| 330 | + | case "workspace": { | |
| 331 | + | const [n, noun] = count(card.projects, "public project", "public projects"); | |
| 332 | + | return frame({ | |
| 333 | + | address: `g1t.sh/${card.slug}`, | |
| 334 | + | // The slug above the name, unless they are the same. | |
| 335 | + | eyebrow: card.name.toLowerCase() === card.slug ? eyebrow("Workspace", false) : eyebrow(card.slug, true), | |
| 336 | + | title: title(card.name, 2), | |
| 337 | + | description: card.description, | |
| 338 | + | facts: [fact(ICON.box, n, noun)], | |
| 339 | + | }); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | case "project": { | |
| 343 | + | const [issues, issueNoun] = count(card.issues, "open issue", "open issues"); | |
| 344 | + | const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests"); | |
| 345 | + | const named = card.name.toLowerCase() !== card.repo.toLowerCase(); | |
| 346 | + | return frame({ | |
| 347 | + | address: `g1t.sh/${card.owner}/${card.repo}`, | |
| 348 | + | eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true), | |
| 349 | + | title: named ? title(card.name, 2) : repoTitle(card.repo), | |
| 350 | + | description: card.description, | |
| 351 | + | facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)], | |
| 352 | + | }); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | case "issue": { | |
| 356 | + | const [label, shapes, tone] = ISSUE_BADGE[card.state]; | |
| 357 | + | return frame({ | |
| 358 | + | address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`, | |
| 359 | + | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), | |
| 360 | + | title: title(card.title), | |
| 361 | + | description: null, | |
| 362 | + | facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author)], | |
| 363 | + | }); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | case "pull": { | |
| 367 | + | const [label, shapes, tone] = PULL_BADGE[card.state]; | |
| 368 | + | return frame({ | |
| 369 | + | address: `g1t.sh/${card.owner}/${card.repo}/pull/${card.number}`, | |
| 370 | + | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), | |
| 371 | + | title: title(card.title), | |
| 372 | + | description: null, | |
| 373 | + | facts: [badge(label, shapes, tone), plain("Pull request"), dot(), byline(card.author)], | |
| 374 | + | }); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | case "soon": | |
| 378 | + | return frame({ | |
| 379 | + | address: `g1t.sh/${card.owner}/${card.repo}`, | |
| 380 | + | eyebrow: eyebrow(`${card.owner}/${card.repo}`, true), | |
| 381 | + | title: title(card.title, 2), | |
| 382 | + | description: card.summary, | |
| 383 | + | facts: [badge("Soon", ICON.sparkles, TONE.open), plain(card.section)], | |
| 384 | + | }); | |
| 385 | + | ||
| 386 | + | case "docs": | |
| 387 | + | return frame({ | |
| 388 | + | address: "docs.g1t.sh", | |
| 389 | + | suffix: "docs", | |
| 390 | + | eyebrow: eyebrow(card.section ?? "Documentation", false), | |
| 391 | + | title: title(card.title, 2), | |
| 392 | + | description: card.description, | |
| 393 | + | facts: [fact(ICON.book, "g1t docs", "Guides and API reference")], | |
| 394 | + | }); | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | /** A repository's name in mono, large: the project's own address. */ | |
| 399 | + | function repoTitle(name: string): Node { | |
| 400 | + | const bounded = bound(name, 60); | |
| 401 | + | const size = bounded.length <= 16 ? 84 : bounded.length <= 26 ? 68 : 52; | |
| 402 | + | return clamp(bounded, 2, { | |
| 403 | + | fontFamily: MONO, | |
| 404 | + | fontWeight: 500, | |
| 405 | + | fontSize: size, | |
| 406 | + | lineHeight: 1.12, | |
| 407 | + | letterSpacing: -1, | |
| 408 | + | color: COLOR.fg, | |
| 409 | + | width: 940, | |
| 410 | + | }); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | function eyebrowWithNumber(repo: string, number: number): Node { | |
| 414 | + | return h( | |
| 415 | + | "div", | |
| 416 | + | { display: "flex", alignItems: "baseline", fontFamily: MONO, fontWeight: 500, fontSize: 28 }, | |
| 417 | + | h("div", { display: "flex", color: COLOR.lavender }, bound(repo, 48)), | |
| 418 | + | h("div", { display: "flex", color: COLOR.faint, marginLeft: 14 }, `#${number}`), | |
| 419 | + | ); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | function byline(author: string): Node { | |
| 423 | + | return h( | |
| 424 | + | "div", | |
| 425 | + | { display: "flex", alignItems: "center", gap: 10, fontFamily: SANS, fontSize: 28, color: COLOR.muted }, | |
| 426 | + | "by", | |
| 427 | + | h("div", { display: "flex", fontFamily: MONO, fontSize: 26, color: COLOR.fgSoft }, bound(author, 32)), | |
| 428 | + | ); | |
| 429 | + | } |
| 1 | + | Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) | |
| 2 | + | ||
| 3 | + | This Font Software is licensed under the SIL Open Font License, Version 1.1. | |
| 4 | + | This license is copied below, and is also available with a FAQ at: | |
| 5 | + | http://scripts.sil.org/OFL | |
| 6 | + | ||
| 7 | + | ----------------------------------------------------------- | |
| 8 | + | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | |
| 9 | + | ----------------------------------------------------------- | |
| 10 | + | ||
| 11 | + | PREAMBLE | |
| 12 | + | The goals of the Open Font License (OFL) are to stimulate worldwide | |
| 13 | + | development of collaborative font projects, to support the font creation | |
| 14 | + | efforts of academic and linguistic communities, and to provide a free and | |
| 15 | + | open framework in which fonts may be shared and improved in partnership | |
| 16 | + | with others. | |
| 17 | + | ||
| 18 | + | The OFL allows the licensed fonts to be used, studied, modified and | |
| 19 | + | redistributed freely as long as they are not sold by themselves. The | |
| 20 | + | fonts, including any derivative works, can be bundled, embedded, | |
| 21 | + | redistributed and/or sold with any software provided that any reserved | |
| 22 | + | names are not used by derivative works. The fonts and derivatives, | |
| 23 | + | however, cannot be released under any other type of license. The | |
| 24 | + | requirement for fonts to remain under this license does not apply | |
| 25 | + | to any document created using the fonts or their derivatives. | |
| 26 | + | ||
| 27 | + | DEFINITIONS | |
| 28 | + | "Font Software" refers to the set of files released by the Copyright | |
| 29 | + | Holder(s) under this license and clearly marked as such. This may | |
| 30 | + | include source files, build scripts and documentation. | |
| 31 | + | ||
| 32 | + | "Reserved Font Name" refers to any names specified as such after the | |
| 33 | + | copyright statement(s). | |
| 34 | + | ||
| 35 | + | "Original Version" refers to the collection of Font Software components as | |
| 36 | + | distributed by the Copyright Holder(s). | |
| 37 | + | ||
| 38 | + | "Modified Version" refers to any derivative made by adding to, deleting, | |
| 39 | + | or substituting -- in part or in whole -- any of the components of the | |
| 40 | + | Original Version, by changing formats or by porting the Font Software to a | |
| 41 | + | new environment. | |
| 42 | + | ||
| 43 | + | "Author" refers to any designer, engineer, programmer, technical | |
| 44 | + | writer or other person who contributed to the Font Software. | |
| 45 | + | ||
| 46 | + | PERMISSION AND CONDITIONS | |
| 47 | + | Permission is hereby granted, free of charge, to any person obtaining | |
| 48 | + | a copy of the Font Software, to use, study, copy, merge, embed, modify, | |
| 49 | + | redistribute, and sell modified and unmodified copies of the Font | |
| 50 | + | Software, subject to the following conditions: | |
| 51 | + | ||
| 52 | + | 1) Neither the Font Software nor any of its individual components, | |
| 53 | + | in Original or Modified Versions, may be sold by itself. | |
| 54 | + | ||
| 55 | + | 2) Original or Modified Versions of the Font Software may be bundled, | |
| 56 | + | redistributed and/or sold with any software, provided that each copy | |
| 57 | + | contains the above copyright notice and this license. These can be | |
| 58 | + | included either as stand-alone text files, human-readable headers or | |
| 59 | + | in the appropriate machine-readable metadata fields within text or | |
| 60 | + | binary files as long as those fields can be easily viewed by the user. | |
| 61 | + | ||
| 62 | + | 3) No Modified Version of the Font Software may use the Reserved Font | |
| 63 | + | Name(s) unless explicit written permission is granted by the corresponding | |
| 64 | + | Copyright Holder. This restriction only applies to the primary font name as | |
| 65 | + | presented to the users. | |
| 66 | + | ||
| 67 | + | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | |
| 68 | + | Software shall not be used to promote, endorse or advertise any | |
| 69 | + | Modified Version, except to acknowledge the contribution(s) of the | |
| 70 | + | Copyright Holder(s) and the Author(s) or with their explicit written | |
| 71 | + | permission. | |
| 72 | + | ||
| 73 | + | 5) The Font Software, modified or unmodified, in part or in whole, | |
| 74 | + | must be distributed entirely under this license, and must not be | |
| 75 | + | distributed under any other license. The requirement for fonts to | |
| 76 | + | remain under this license does not apply to any document created | |
| 77 | + | using the Font Software. | |
| 78 | + | ||
| 79 | + | TERMINATION | |
| 80 | + | This license becomes null and void if any of the above conditions are | |
| 81 | + | not met. | |
| 82 | + | ||
| 83 | + | DISCLAIMER | |
| 84 | + | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 85 | + | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 86 | + | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 87 | + | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | |
| 88 | + | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 89 | + | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 90 | + | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 91 | + | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 92 | + | OTHER DEALINGS IN THE FONT SOFTWARE. |
| 1 | + | Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) | |
| 2 | + | ||
| 3 | + | This Font Software is licensed under the SIL Open Font License, Version 1.1. | |
| 4 | + | This license is copied below, and is also available with a FAQ at: | |
| 5 | + | https://openfontlicense.org | |
| 6 | + | ||
| 7 | + | ||
| 8 | + | ----------------------------------------------------------- | |
| 9 | + | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | |
| 10 | + | ----------------------------------------------------------- | |
| 11 | + | ||
| 12 | + | PREAMBLE | |
| 13 | + | The goals of the Open Font License (OFL) are to stimulate worldwide | |
| 14 | + | development of collaborative font projects, to support the font creation | |
| 15 | + | efforts of academic and linguistic communities, and to provide a free and | |
| 16 | + | open framework in which fonts may be shared and improved in partnership | |
| 17 | + | with others. | |
| 18 | + | ||
| 19 | + | The OFL allows the licensed fonts to be used, studied, modified and | |
| 20 | + | redistributed freely as long as they are not sold by themselves. The | |
| 21 | + | fonts, including any derivative works, can be bundled, embedded, | |
| 22 | + | redistributed and/or sold with any software provided that any reserved | |
| 23 | + | names are not used by derivative works. The fonts and derivatives, | |
| 24 | + | however, cannot be released under any other type of license. The | |
| 25 | + | requirement for fonts to remain under this license does not apply | |
| 26 | + | to any document created using the fonts or their derivatives. | |
| 27 | + | ||
| 28 | + | DEFINITIONS | |
| 29 | + | "Font Software" refers to the set of files released by the Copyright | |
| 30 | + | Holder(s) under this license and clearly marked as such. This may | |
| 31 | + | include source files, build scripts and documentation. | |
| 32 | + | ||
| 33 | + | "Reserved Font Name" refers to any names specified as such after the | |
| 34 | + | copyright statement(s). | |
| 35 | + | ||
| 36 | + | "Original Version" refers to the collection of Font Software components as | |
| 37 | + | distributed by the Copyright Holder(s). | |
| 38 | + | ||
| 39 | + | "Modified Version" refers to any derivative made by adding to, deleting, | |
| 40 | + | or substituting -- in part or in whole -- any of the components of the | |
| 41 | + | Original Version, by changing formats or by porting the Font Software to a | |
| 42 | + | new environment. | |
| 43 | + | ||
| 44 | + | "Author" refers to any designer, engineer, programmer, technical | |
| 45 | + | writer or other person who contributed to the Font Software. | |
| 46 | + | ||
| 47 | + | PERMISSION & CONDITIONS | |
| 48 | + | Permission is hereby granted, free of charge, to any person obtaining | |
| 49 | + | a copy of the Font Software, to use, study, copy, merge, embed, modify, | |
| 50 | + | redistribute, and sell modified and unmodified copies of the Font | |
| 51 | + | Software, subject to the following conditions: | |
| 52 | + | ||
| 53 | + | 1) Neither the Font Software nor any of its individual components, | |
| 54 | + | in Original or Modified Versions, may be sold by itself. | |
| 55 | + | ||
| 56 | + | 2) Original or Modified Versions of the Font Software may be bundled, | |
| 57 | + | redistributed and/or sold with any software, provided that each copy | |
| 58 | + | contains the above copyright notice and this license. These can be | |
| 59 | + | included either as stand-alone text files, human-readable headers or | |
| 60 | + | in the appropriate machine-readable metadata fields within text or | |
| 61 | + | binary files as long as those fields can be easily viewed by the user. | |
| 62 | + | ||
| 63 | + | 3) No Modified Version of the Font Software may use the Reserved Font | |
| 64 | + | Name(s) unless explicit written permission is granted by the corresponding | |
| 65 | + | Copyright Holder. This restriction only applies to the primary font name as | |
| 66 | + | presented to the users. | |
| 67 | + | ||
| 68 | + | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | |
| 69 | + | Software shall not be used to promote, endorse or advertise any | |
| 70 | + | Modified Version, except to acknowledge the contribution(s) of the | |
| 71 | + | Copyright Holder(s) and the Author(s) or with their explicit written | |
| 72 | + | permission. | |
| 73 | + | ||
| 74 | + | 5) The Font Software, modified or unmodified, in part or in whole, | |
| 75 | + | must be distributed entirely under this license, and must not be | |
| 76 | + | distributed under any other license. The requirement for fonts to | |
| 77 | + | remain under this license does not apply to any document created | |
| 78 | + | using the Font Software. | |
| 79 | + | ||
| 80 | + | TERMINATION | |
| 81 | + | This license becomes null and void if any of the above conditions are | |
| 82 | + | not met. | |
| 83 | + | ||
| 84 | + | DISCLAIMER | |
| 85 | + | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 86 | + | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 87 | + | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 88 | + | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | |
| 89 | + | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 90 | + | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 91 | + | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 92 | + | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 93 | + | OTHER DEALINGS IN THE FONT SOFTWARE. |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.