Skip to content

g1t/apps/web/app/lib/project-kind.ts

222 lines10,290 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A project is an app or a library: libraries show their package and how to ship a release, not production1/**
2 * Words and links for a project that is a library or a tool rather than an
3 * app: which of its repository's packages to show, and how to publish a
4 * first one. Whether a project is one is decided by the projects service
5 * (`Project.kind`); this only says it on the pages.
6 */
Merge main: Deployments panel in the About, project homepage, both sides' operations7import type {
8 Ecosystem,
9 PackageSummary,
10 Project,
11 ProjectChanges,
12 ProjectEcosystem,
13 ProjectKind,
14 ProjectLinks,
15 ProjectRuns,
16 ProjectSetting,
17} from "@g1t/contracts";
A project is an app or a library: libraries show their package and how to ship a release, not production18
19/** As lib/packages.ts names each registry; repeated so this module stands alone in tests. */
Merge branch 'worktree-agent-a6a121745e81f639f'20const REGISTRY: Record<Ecosystem, string> = {
21 container: "Container",
22 npm: "npm",
23 composer: "Composer",
24 cargo: "Cargo",
25 go: "Go",
26 maven: "Maven",
27 nuget: "NuGet",
28 rubygems: "RubyGems",
29};
A project is an app or a library: libraries show their package and how to ship a release, not production30
31const DOCS = "https://docs.g1t.sh";
32
Merge main: Deployments panel in the About, project homepage, both sides' operations33/** One choice of what a project is, as its settings and overview offer it. */
34export type KindChoice = "auto" | "g1t" | "elsewhere" | "library" | "tool" | "docs" | "other";
35
36/** What each choice sets: a kind and where it runs, `auto` for what is left to detection. */
37export const KIND_CHOICE_SETS: Record<KindChoice, { kind: ProjectKind | "auto"; runs: ProjectRuns | "auto" }> = {
38 auto: { kind: "auto", runs: "auto" },
39 g1t: { kind: "app", runs: "g1t" },
40 elsewhere: { kind: "app", runs: "elsewhere" },
41 library: { kind: "library", runs: "auto" },
42 tool: { kind: "tool", runs: "auto" },
43 docs: { kind: "docs", runs: "auto" },
44 other: { kind: "other", runs: "auto" },
45};
46
47/** The choices for "What it is" in a project's settings, in order. */
48export const KIND_CHOICES: { value: KindChoice; label: string; hint: string }[] = [
A project is an app or a library: libraries show their package and how to ship a release, not production49 { value: "auto", label: "Detect automatically", hint: "From whether Deployments are on, the packages it publishes and the files at its root." },
Merge main: Deployments panel in the About, project homepage, both sides' operations50 { value: "g1t", label: "App or site, deployed on g1t", hint: "g1t builds it: production on g1t.page from the default branch, and a preview for every pull request." },
51 {
52 value: "elsewhere",
53 label: "App or site, deployed elsewhere",
54 hint: "Your own pipeline deploys it. Its overview shows production at the address you give.",
55 },
56 { value: "library", label: "Library or package", hint: "Installed by other code: its overview shows its packages and releases." },
57 { value: "tool", label: "Tool or CLI", hint: "Installed and run by people: its overview shows its releases and packages." },
58 { value: "docs", label: "Documentation", hint: "Docs or site content: its overview shows where they are read." },
59 { value: "other", label: "Something else", hint: "Configuration, research, notes: its overview shows its links and its work." },
A project is an app or a library: libraries show their package and how to ship a release, not production60];
61
Merge main: Deployments panel in the About, project homepage, both sides' operations62/** The choice a project's setting is; null when it is one no choice makes (set through the API). */
63export function choiceOf(setting: ProjectSetting): KindChoice | null {
64 if (!setting.kind && !setting.runs) return "auto";
65 if (setting.kind && setting.kind !== "app") return setting.kind;
66 if (setting.runs) return setting.runs;
67 return null;
68}
69
70/** Whether it is set to be something that never deploys: a library, a tool or other. */
71export function neverDeploys(project: Pick<Project, "setting">): boolean {
72 const kind = project.setting?.kind;
73 return kind != null && kind !== "app" && kind !== "docs";
74}
75
76/** What a project is, in a word or three, for its badge. */
77export function kindLabel(project: Pick<Project, "kind" | "runs">): string {
78 switch (project.kind) {
79 case "app":
80 return project.runs === "g1t" ? "App on g1t" : project.runs === "elsewhere" ? "App, deployed elsewhere" : "App";
81 case "library":
82 return "Library";
83 case "tool":
84 return "Tool";
85 case "docs":
86 return project.runs === "g1t" ? "Docs on g1t" : "Docs";
87 default:
88 return "Project";
89 }
90}
91
92/** A link as pages list it: what it is, its label and address. */
93export type ShownLink = { key: string; type: "homepage" | "docs" | "production" | "custom"; label: string; url: string };
94
95/** An address without its scheme, or a trailing slash: `g1t.sh/docs`. */
96export function bare(url: string): string {
97 return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
98}
99
100/**
101 * A project's links in the order pages show them: its homepage, its docs,
102 * then the rest. An address already shown elsewhere on the page (such as
103 * production's) is given in `shown` and left out, so nothing is listed twice.
104 */
105export function linksToShow(links: ProjectLinks, shown: (string | null | undefined)[] = []): ShownLink[] {
106 const seen = new Set(shown.filter((url): url is string => !!url).map((url) => bare(url).toLowerCase()));
107 const out: ShownLink[] = [];
108 const add = (link: ShownLink) => {
109 const key = bare(link.url).toLowerCase();
110 if (seen.has(key)) return;
111 seen.add(key);
112 out.push(link);
113 };
114 if (links.homepage) add({ key: "homepage", type: "homepage", label: bare(links.homepage), url: links.homepage });
115 if (links.docs) add({ key: "docs", type: "docs", label: "Docs", url: links.docs });
116 links.custom.forEach((link, index) => add({ key: `custom:${index}`, type: "custom", label: link.label, url: link.url }));
117 return out;
118}
119
120/**
121 * The one address a project's card or row shows: production as g1t
122 * serves it (`live`, from Deployments), production deployed elsewhere,
123 * then its homepage, then its docs. Null when it has none.
124 */
125export function primaryLink(project: Pick<Project, "runs" | "productionUrl" | "links">, live: string | null | undefined): string | null {
126 if (project.runs === "g1t" && live) return live;
127 if (project.runs === "elsewhere" && project.productionUrl) return project.productionUrl;
128 return live ?? project.links?.homepage ?? project.links?.docs ?? null;
129}
130
131/**
132 * The change a form on the overview asks for: only the fields it carries.
133 * `choice` sets what it is and where it runs; `links` says its rows are the
134 * whole list of other links, so a form with every row removed clears them.
135 */
136export function projectChanges(form: Pick<FormData, "get" | "getAll" | "has">): ProjectChanges {
137 const changes: ProjectChanges = {};
138 const choice = form.get("choice");
139 if (typeof choice === "string" && choice in KIND_CHOICE_SETS) Object.assign(changes, KIND_CHOICE_SETS[choice as KindChoice]);
140 const text = (name: string) => (form.has(name) ? String(form.get(name) ?? "") : undefined);
141 const fields = { description: text("description"), productionUrl: text("productionUrl"), homepage: text("homepage"), docsUrl: text("docsUrl") };
142 for (const [key, value] of Object.entries(fields)) if (value !== undefined) (changes as Record<string, unknown>)[key] = value;
143 if (form.has("links")) changes.links = linksFromForm(form);
144 return changes;
145}
146
147/** A repository's newest tag, by its commit's date: its latest release. */
148export function latestTag(tags: { name: string; commit: { authoredAt: string } | null }[]): { name: string; at: string | null } | null {
149 const dated = tags.filter((tag) => tag.commit).sort((a, b) => b.commit!.authoredAt.localeCompare(a.commit!.authoredAt));
150 const newest = dated[0] ?? tags[0];
151 return newest ? { name: newest.name, at: newest.commit?.authoredAt ?? null } : null;
152}
153
154/** The custom links a form gives, as rows of `linkLabel` and `linkUrl` fields, in order. */
155export function linksFromForm(form: Pick<FormData, "getAll">): { label: string; url: string }[] {
156 const labels = form.getAll("linkLabel").map(String);
157 const urls = form.getAll("linkUrl").map(String);
158 return urls.map((url, index) => ({ label: labels[index] ?? "", url })).filter((link) => link.label.trim() || link.url.trim());
159}
160
A project is an app or a library: libraries show their package and how to ship a release, not production161/** A registry's guide, and the command that publishes a first version there. */
162export type PublishGuide = { label: string; guide: string; start: string | null };
163
164/** The registries with a guide, for a library whose files do not say which it is. */
165export const PUBLISH_GUIDES: PublishGuide[] = [
166 { label: "Composer", guide: `${DOCS}/guides/composer/`, start: "git tag v1.0.0 && git push --tags" },
167 { label: "npm", guide: `${DOCS}/guides/npm/`, start: "npm publish" },
168 { label: "Go", guide: `${DOCS}/guides/go/`, start: "git tag v1.0.0 && git push --tags" },
169 { label: "Containers", guide: `${DOCS}/guides/containers/`, start: null },
170];
171
172/** How a library of `ecosystem` publishes its first version; null when its files do not say. */
173export function publishGuide(ecosystem: ProjectEcosystem | null): PublishGuide | null {
174 switch (ecosystem) {
175 case "composer":
176 return PUBLISH_GUIDES[0]!;
177 case "npm":
178 return PUBLISH_GUIDES[1]!;
179 case "go":
180 return PUBLISH_GUIDES[2]!;
181 case "cargo":
182 return { label: "Cargo", guide: `${DOCS}/guides/packages/`, start: null };
183 case "python":
184 return { label: "Python", guide: `${DOCS}/guides/packages/`, start: null };
185 default:
186 return null;
187 }
188}
189
190/** A package as its own tool names it: `@acme/ui` for npm, the name otherwise. */
191export function packageName(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
192 return pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name;
193}
194
195/** `Composer · psr/log 3.0.2`, for a project's card. */
196export function packageLine(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace" | "latest">): string {
197 return `${REGISTRY[pkg.ecosystem]} · ${packageName(pkg)}${pkg.latest ? ` ${pkg.latest}` : ""}`;
198}
199
200/** The package's page. */
201export function packagePath(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
202 return `/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`;
203}
204
205/**
206 * The packages a library's overview shows, from its repository's: what it
207 * publishes for others to install first, a container image last, as an
208 * image is how apps ship too.
209 */
210export function libraryPackages<T extends Pick<PackageSummary, "ecosystem" | "versions" | "updated_at">>(list: T[]): T[] {
211 return [...list].sort(
212 (a, b) =>
213 Number(a.ecosystem === "container") - Number(b.ecosystem === "container") ||
214 Number(b.versions > 0) - Number(a.versions > 0) ||
215 b.updated_at.localeCompare(a.updated_at),
216 );
217}
218
219/** Whether any of the packages has a version: a release is out. */
220export function hasRelease(list: Pick<PackageSummary, "versions">[]): boolean {
221 return list.some((pkg) => pkg.versions > 0);
222}

This file's history is long; its oldest lines are credited to the oldest commit read.