| 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 | |
| 10 | import 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. |
| 14 | const MAX_PROJECT_LINKS = 10; |
| 15 | const MAX_LINK_LABEL = 40; |
| 16 | const MAX_LINK_URL = 255; |
| 17 | |
| 18 | export 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. */ |
| 21 | export 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 | */ |
| 50 | export 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. */ |
| 69 | export 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 | */ |
| 87 | export 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 | } |