1import type { Context, Hono } from "hono";2import { syncAll } from "../bucket/sync.ts";3import { operatorIssues } from "../issues/store.ts";4import { languageLines, repoStats } from "../mirror/queries.ts";5import { openMirror, quarantinedPaths } from "../mirror/store.ts";6import { html } from "../pages/html.ts";7import { formatDate, plural } from "../pages/layout.ts";8import { operatorReleases, visibleReleases } from "../releases/releases.ts";9import { type RepoText, repoText, setRepoText } from "../server/db.ts";10import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";11import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";12import { LIMITS } from "./text.ts";1314const MAX_FIELD = 500;15const HTTP_URL = /^https?:\/\/[^\s]+$/i;16const MAX_LANGUAGES = 5;17const MAX_LANGUAGE = 30;181920function languageNames(value: string): string[] {21 return value22 .split(",")23 .map((name) => name.trim())24 .filter((name) => name !== "");25}262728export function registerRepos(app: Hono, ctx: AdminContext): void {29 app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));3031 app.post("/admin/sync", async (c) => {32 await syncAll(ctx.sync);33 return c.redirect("/admin/repos", 303);34 });3536 app.post("/admin/repos/:repo", async (c) => {37 const repo = findRepo(ctx.kepr, c.req.param("repo"));38 if (!repo) return c.notFound();39 const fields = await textFields(c);40 const text = (name: string) => (fields[name] ?? "").trim();41 const languages = languageNames(fields.languages ?? "");42 const edit: RepoEdit = {43 public: fields.public === "1" ? 1 : 0,44 category: text("category"),45 homepage: text("homepage"),46 47 languages: languages.join(", "),48 issues_paused: fields.issues_paused === "1" ? 1 : 0,49 };50 const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);51 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [52 field,53 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),54 ]);55 const long = texts.find(([field, value]) => value.length > LIMITS[field]);56 if (long) {57 const [field] = long;58 return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);59 }60 if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {61 return refuse(`keep each field under ${MAX_FIELD} characters.`);62 }63 if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {64 return refuse("the homepage must be an http or https address.");65 }66 if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");67 if (languages.some((name) => name.length > MAX_LANGUAGE)) {68 return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);69 }70 if (languages.some((name) => /\p{C}/u.test(name))) {71 return refuse("a language name is printable text.");72 }73 updateRepo(ctx.kepr, repo.name, edit);74 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);75 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);76 });77}7879function reposPage(c: Context, ctx: AdminContext, error: string): string {80 const repos = allRepos(ctx.kepr);81 const selected = repos.find((repo) => repo.name === c.req.query("repo")) ?? repos[0];82 const body = html`${notice(error)}83<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>84<form method="post" action="/admin/sync">85${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>86${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}87<div class="admin-repos">88<nav aria-label="repositories">89${repos.map(90 (repo) => html`<a class="issue-row" id="repo-${repo.name}"91href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>92<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>93<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>94${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,95)}96</nav>97${selected && repoForm(c, ctx, selected)}98</div>`;99 return adminPage(c, ctx, "repos", body);100}101102103function workspace(ctx: AdminContext, repo: AdminRepo) {104 const mirror = openMirror(ctx.mirrorDir, repo.name);105 const stats = repoStats(mirror);106 const releases = operatorReleases(ctx.kepr, mirror, repo.name);107 const downloads = (release: (typeof releases)[number]) =>108 release.downloads.reduce((sum, [, count]) => sum + count, 0);109 const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);110 const vital = (label: string, value: string | number) =>111 html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;112 return html`<div class="admin-vitals">113${vital("saves", stats.saves)}114${vital("lines", stats.lines)}115${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}116${vital("open issues", open.length)}117${vital(118 "downloads",119 releases.reduce((sum, release) => sum + downloads(release), 0),120)}121</div>122<p class="issue-meta mono">detected languages:123${languageLines(mirror).map(124 ([language, lines], i) =>125 html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,126)}</p>127${128 releases.length > 0 &&129 html`<ul class="admin-releases mono">${releases.map(130 (release) => html`<li><a href="/admin/releases#${repo.name}-${release.version}">131${release.version}</a> <span class="muted">${release.hidden ? "hidden" : "shown"} · ${plural(132 downloads(release),133 "download",134 )}</span></li>`,135 )}</ul>`136}`;137}138139function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {140 const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));141 const own = repoText(ctx.kepr, repo.name);142 const input = (name: keyof RepoEdit, label: string) =>143 html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>144<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;145 const synced =146 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;147 return html`<article class="issue-post" id="repo-editor">148<p class="issue-post-head mono">149<span class="issue-author">150${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>151<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>152${workspace(ctx, repo)}153${154 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`155}156${157 quarantined.length > 0 &&158 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`159}160<form class="issues-form" method="post" action="/admin/repos/${repo.name}">161${formField(c)}162<label class="issues-label">163<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>164<label class="issues-label">165<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>166pause new issues</label>167${input("category", "category")}168${input("homepage", "homepage")}169${input("languages", "shown languages")}170<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>171${textArea(repo.name, "description", "description", 3, own.description)}172${textArea(repo.name, "details", "details", 12, own.details)}173<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>174<button class="issues-submit" type="submit">save ${repo.name}</button>175</form>176</article>`;177}178179function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {180 return html`<label class="issues-label" for="${repo}-${name}">${label}</label>181<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"182maxlength="${String(LIMITS[name])}">${value}</textarea>`;183}