Skip to content

g1t/services/projects/src/links.ts

102 lines4,330 bytesCodeBlame
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
75 .filter((link): link is ProjectLink => typeof link?.label === "string" && typeof link?.url === "string")
76 .slice(0, MAX_PROJECT_LINKS);
77 } catch {
78 return [];
79 }
80}
81
82/**
83 * The links a project shows, from its row. Only a repository's own
84 * project (its primary one) follows the repository's website: another
85 * project in it, from a directory of its own, is something else.
86 */
87export function projectLinks(row: {
88 is_primary?: number;
89 homepage?: string | null;
90 repo_website?: string | null;
91 docs_url?: string | null;
92 links?: string | null;
93}): ProjectLinks {
94 const own = row.homepage ?? null;
95 const repo = row.is_primary ? (row.repo_website ?? null) : null;
96 return {
97 homepage: own ?? repo,
98 homepageInherited: own == null && repo != null,
99 docs: row.docs_url ?? null,
100 custom: storedLinks(row.links),
101 };
102}