Skip to content

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

100 linesCodeBlameRaw
1/**
2 * A project's links: its homepage, its docs, any others, and production's
3 * address when it is deployed elsewhere. Each is an http(s) address,
4 * tidied as a repository's website is (crates/contracts/src/repos.rs
5 * `clean_website`): `https://` added when no scheme is given, anything else
6 * refused. A repository's own project follows its website as homepage
7 * until the project is given one of its own, as its description does.
8 */
9
10import type { ProjectLink, ProjectLinks } from "@g1t/contracts";
11
12// As contracts' MAX_PROJECT_LINKS, MAX_LINK_LABEL and MAX_LINK_URL; repeated
13// so this module's tests run without the package.
14const MAX_PROJECT_LINKS = 10;
15const MAX_LINK_LABEL = 40;
16const MAX_LINK_URL = 255;
17
18export type Cleaned<T> = { ok: true; value: T } | { ok: false; message: string };
19
20/** An address as it is kept; null for blank. `what` names it in the refusal. */
21export function cleanUrl(text: string | null | undefined, what = "A link"): Cleaned<string | null> {
22 const trimmed = (text ?? "").trim();
23 if (!trimmed) return { ok: true, value: null };
24 let url: string;
25 if (/^https?:\/\//i.test(trimmed)) url = trimmed.replace(/^https?/i, (scheme) => scheme.toLowerCase());
26 else if (trimmed.includes("://") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed.split("/")[0]!.replace(/:\d+$/, ""))) {
27 return { ok: false, message: `${what} is an http or https address.` };
28 } else url = `https://${trimmed}`;
29 const host = url.split("://")[1]!.split(/[/?#]/)[0]!;
30 const name = host.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
31 if (
32 url.length > MAX_LINK_URL ||
33 /\s/.test(url) ||
34 !name.includes(".") ||
35 name.startsWith(".") ||
36 name.endsWith(".") ||
37 !/^[a-z0-9.-]+$/i.test(name) ||
38 host.includes("@")
39 ) {
40 return { ok: false, message: `${what} is not a web address, such as https://example.com.` };
41 }
42 return { ok: true, value: url };
43}
44
45/**
46 * The links to keep: each with a label and an address, blank rows left
47 * out, at most `MAX_PROJECT_LINKS`. A label is the address's host when
48 * none is given.
49 */
50export function cleanLinks(input: unknown): Cleaned<ProjectLink[]> {
51 if (!Array.isArray(input)) return { ok: false, message: "Give links as a list of labels and addresses." };
52 const kept: ProjectLink[] = [];
53 for (const item of input) {
54 const entry = (item ?? {}) as { label?: unknown; url?: unknown };
55 const label = typeof entry.label === "string" ? entry.label.trim().replace(/\s+/g, " ") : "";
56 const raw = typeof entry.url === "string" ? entry.url : "";
57 if (!label && !raw.trim()) continue;
58 const url = cleanUrl(raw, label ? `The address for ${label}` : "A link");
59 if (!url.ok) return url;
60 if (!url.value) return { ok: false, message: `Give ${label} an address.` };
61 if (label.length > MAX_LINK_LABEL) return { ok: false, message: `A link's label is at most ${MAX_LINK_LABEL} characters.` };
62 kept.push({ label: label || url.value.split("://")[1]!.split(/[/?#]/)[0]!, url: url.value });
63 }
64 if (kept.length > MAX_PROJECT_LINKS) return { ok: false, message: `A project keeps at most ${MAX_PROJECT_LINKS} links besides its homepage and docs.` };
65 return { ok: true, value: kept };
66}
67
68/** Links as stored: a JSON list; anything unreadable is none. */
69export function storedLinks(text: string | null | undefined): ProjectLink[] {
70 if (!text) return [];
71 try {
72 const parsed = JSON.parse(text) as unknown;
73 if (!Array.isArray(parsed)) return [];
74 return parsed.filter((link): link is ProjectLink => typeof link?.label === "string" && typeof link?.url === "string").slice(0, MAX_PROJECT_LINKS);
75 } catch {
76 return [];
77 }
78}
79
80/**
81 * The links a project shows, from its row. Only a repository's own
82 * project (its primary one) follows the repository's website: another
83 * project in it, from a directory of its own, is something else.
84 */
85export function projectLinks(row: {
86 is_primary?: number;
87 homepage?: string | null;
88 repo_website?: string | null;
89 docs_url?: string | null;
90 links?: string | null;
91}): ProjectLinks {
92 const own = row.homepage ?? null;
93 const repo = row.is_primary ? (row.repo_website ?? null) : null;
94 return {
95 homepage: own ?? repo,
96 homepageInherited: own == null && repo != null,
97 docs: row.docs_url ?? null,
98 custom: storedLinks(row.links),
99 };
100}