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
↓ kepr-086289d5.tar.gzatlas at this save
files changed4 files
package.json+1 −1src/admin/repos.ts+23 −5src/admin/text.ts+1 −1tests/repoworkspace.test.ts+17 −6package.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": {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 }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.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 () => {