086289d5af85

edit a repo's description and details from its admin page (v3.0.142)

time
2026-09-30 13:45 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
71860b49096f
4 files package.json+1 −1src/admin/repos.ts+23 −5src/admin/text.ts+1 −1tests/repoworkspace.test.ts+17 −6
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.141",3+	"version": "3.0.142",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+23 −5
@@ -6,9 +6,10 @@66 import { html } from "../pages/html.ts";77 import { formatDate, plural } from "../pages/layout.ts";88 import { operatorReleases, visibleReleases } from "../releases/releases.ts";9-import { repoText } from "../server/db.ts";9+import { type RepoText, repoText, setRepoText } from "../server/db.ts";1010 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";1111 import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";12+import { LIMITS } from "./text.ts";1213 1314 const MAX_FIELD = 500;1415 const HTTP_URL = /^https?:\/\/[^\s]+$/i;@@ -47,7 +48,16 @@4748 			issues_paused: fields.issues_paused === "1" ? 1 : 0,4849 		};4950 		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);50-		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {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)) {5161 			return refuse(`keep each field under ${MAX_FIELD} characters.`);5262 		}5363 		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {@@ -61,6 +71,7 @@6171 			return refuse("a language name is printable text.");6272 		}6373 		updateRepo(ctx.kepr, repo.name, edit);74+		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);6475 		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);6576 	});6677 }@@ -98,7 +109,6 @@98109 	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);99110 	const vital = (label: string, value: string | number) =>100111 		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;101-	const own = repoText(ctx.kepr, repo.name).description !== "";102112 	return html`<div class="admin-vitals">103113 ${vital("saves", stats.saves)}104114 ${vital("lines", stats.lines)}@@ -114,8 +124,6 @@114124 	([language, lines], i) =>115125 		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,116126 )}</p>117-<p class="issue-meta mono">description: ${own ? "your own words" : "from the README"} ·118-<a href="/${repo.name}">edit on its page ↗</a></p>119127 ${120128 	releases.length > 0 &&121129 	html`<ul class="admin-releases mono">${releases.map(@@ -130,6 +138,7 @@130138 131139 function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {132140 	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));141+	const own = repoText(ctx.kepr, repo.name);133142 	const input = (name: keyof RepoEdit, label: string) =>134143 		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>135144 <input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;@@ -159,7 +168,16 @@159168 ${input("homepage", "homepage")}160169 ${input("languages", "shown languages")}161170 <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>162174 <button class="issues-submit" type="submit">save ${repo.name}</button>163175 </form>164176 </article>`;177+}178+179+function 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)}"182+maxlength="${String(LIMITS[name])}">${value}</textarea>`;165183 }

view file

src/admin/text.ts+1 −1
@@ -11,7 +11,7 @@1111 import { textFields } from "./console.ts";1212 import { findRepo } from "./store.ts";1313 14-const LIMITS: Record<RepoText, number> = { description: 2000, details: 60_000 };14+export const LIMITS: Record<RepoText, number> = { description: 2000, details: 60_000 };1515 const MEDIA_ROUTE = "/static/assets/media/:repo/:name{[0-9a-f]{64}\\.(png|jpg|gif|webp|mp4|webm)}";1616 1717 // What an upload is, from its first bytes rather than its name or declared type.

view file

tests/repoworkspace.test.ts+17 −6
@@ -60,13 +60,24 @@6060 		expect(page).toContain('Python <span class="muted">1 line</span>');6161 	});6262 63-	test("says where the description comes from and links to its page", async () => {64-		expect(await workspace()).toContain("description: from the README");65-		inst.kepr.run("INSERT INTO repo_text (repo, description) VALUES ('app', 'Own words')");63+	test("edits the description and details; blank hands them back to the README", async () => {64+		expect(await workspace()).toMatch(/<textarea[^>]*name="description"[^>]*><\/textarea>/);65+		const saved = await save({ description: "Own *words*", details: "## Usage\r\nRun it." });66+		expect(saved.status).toBe(303);6667 		const page = await workspace();67-		expect(page).toContain("description: your own words");68-		expect(page).toContain('<a href="/app">edit on its page ↗</a>');69-		inst.kepr.run("DELETE FROM repo_text");68+		expect(page).toContain("Own *words*</textarea>");69+		expect(page).toContain("## Usage\nRun it.</textarea>");70+		const repo = await (await inst.app.request("/app")).text();71+		expect(repo).toContain("Own <em>words</em>");72+		expect(repo).toContain("Run it.");73+		const long = await save({ details: "x".repeat(60_001) });74+		expect(long.status).toBe(400);75+		expect(await long.text()).toContain("Keep the details under 60,000 characters.");76+		await save({ description: "", details: "" });77+		expect(inst.kepr.query("SELECT description, details FROM repo_text").get()).toEqual({78+			description: "",79+			details: "",80+		});7081 	});7182 7283 	test("lists its releases with visibility and downloads, linking to the Releases section", async () => {

view file