4030bdcacda1

edit a repo's description and details on its page when signed in: Markdown toolbar, live preview, picture, GIF and video uploads sized and placed from the caret, served privately with Range; log in returns to the page (v3.0.108)

time
2026-09-29 18:30 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
repo text editing
parent
386cd633d244
14 files package.json+1 −1public/css/kepr.css+111 −0public/js/kepr.js+212 −2src/admin/text.ts+110 −0src/pages/layout.ts+1 −1src/pages/library.ts+2 −2src/pages/markdown.ts+40 −2src/pages/repo.ts+30 −18src/releases/routes.ts+2 −2src/server/app.ts+13 −5src/server/db.ts+42 −3src/server/login.ts+5 −0tests/front.test.ts+1 −1tests/repotext.test.ts+125 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.107",3+	"version": "3.0.108",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+111 −0
@@ -2034,6 +2034,9 @@20342034 	}20352035 }20362036 @media (max-width: 768px) {2037+	.editor-panes {2038+		grid-template-columns: minmax(0, 1fr);2039+	}20372040 	.fv-header {20382041 		flex-wrap: wrap;20392042 		height: auto;@@ -2068,6 +2071,114 @@20682071 	.profile-event-time {20692072 		display: none;20702073 	}2074+}2075+2076+/* ----- media in Markdown and the signed-in editor ------------------------- */2077+2078+/* Sizes and places from an image's options (markdown.ts); classes, as the policy allows no inline2079+   style. */2080+.markdown .media {2081+	display: block;2082+	max-width: 100%;2083+	height: auto;2084+	margin: 12px auto;2085+	border-radius: 3px;2086+}2087+.markdown .media-left {2088+	margin-left: 0;2089+}2090+.markdown .media-right {2091+	margin-right: 0;2092+}2093+.markdown .media-w25 {2094+	width: 25%;2095+}2096+.markdown .media-w33 {2097+	width: 33%;2098+}2099+.markdown .media-w50 {2100+	width: 50%;2101+}2102+.markdown .media-w66 {2103+	width: 66%;2104+}2105+.markdown .media-w75 {2106+	width: 75%;2107+}2108+.markdown .media-w100 {2109+	width: 100%;2110+}2111+.edit-btn {2112+	margin-left: 12px;2113+	padding: 1px 7px;2114+	background: transparent;2115+	border: 1px solid rgba(200, 154, 90, 0.2);2116+	border-radius: 2px;2117+	font-size: 0.625rem;2118+	color: var(--text-faint);2119+	cursor: pointer;2120+	vertical-align: middle;2121+}2122+.edit-btn:hover {2123+	color: var(--accent);2124+	border-color: var(--accent-dim);2125+}2126+.editor {2127+	display: grid;2128+	gap: 8px;2129+	margin: 8px 0 16px;2130+}2131+.editor-toolbar,2132+.editor-media,2133+.editor-actions {2134+	display: flex;2135+	flex-wrap: wrap;2136+	align-items: center;2137+	gap: 4px;2138+}2139+.editor-media[hidden] {2140+	display: none;2141+}2142+.editor-label,2143+.editor-status {2144+	margin: 0 6px;2145+	font-size: 0.625rem;2146+	color: var(--text-faint);2147+}2148+.editor-panes {2149+	display: grid;2150+	grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);2151+	gap: 12px;2152+}2153+.editor-input {2154+	width: 100%;2155+	min-height: 4lh;2156+	max-height: 70vh;2157+	field-sizing: content;2158+	resize: vertical;2159+	padding: 10px 12px;2160+	background: var(--bg-surface);2161+	border: 1px solid var(--border);2162+	border-radius: 3px;2163+	color: var(--text);2164+	font-size: 0.75rem;2165+	line-height: 1.6;2166+}2167+.editor-input:focus {2168+	outline: none;2169+	border-color: var(--accent-dim);2170+}2171+.editor-preview {2172+	min-width: 0;2173+	max-height: 70vh;2174+	overflow-y: auto;2175+	padding: 0 4px;2176+}2177+.editor-preview:empty::before {2178+	content: attr(data-empty);2179+	font-family: var(--mono);2180+	font-size: 0.625rem;2181+	color: var(--text-faint);20712182 }20722183 20732184 /* ----- file viewer (Kepr 2's Atlas modal) -------------------------------- */

view file

public/js/kepr.js+212 −2
@@ -1,7 +1,7 @@11 // Kepr's browser islands (§18). Every page is complete without this file; it only adds the22 // ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling,3-// line-range highlighting, the Atlas panel, picker, filter and file viewer, and the profile's4-// glow.3+// line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in4+// editor, and the profile's glow.55 66 // Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.77 const KINDS = [@@ -602,6 +602,215 @@602602 	return node;603603 }604604 605+// Signed in, a repo page's description and details are edited in place: Markdown with a toolbar606+// and a preview as the page will show it, pictures and videos picked, dropped or pasted in, and607+// the one under the caret sized and placed from a row of controls. The page's HTML is the same608+// for everyone; only this cookie, which says nothing but "signed in here", brings the controls.609+async function repoEditor() {610+	const head = document.querySelector(".repo-head");611+	if (!head || !/(^|;\s*)kepr_editor=1/.test(document.cookie)) return;612+	const base = `/admin/repos/${location.pathname.split("/")[1]}`;613+	const response = await fetch(`${base}/text`).catch(() => null);614+	if (!response?.ok) return;615+	const texts = await response.json();616+	const details = document.querySelector(".repo-details");617+	const places = [618+		["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")],619+		["details", details, details.querySelector(".repo-details-body")],620+	];621+	for (const [field, section, body] of places) {622+		section.hidden = false;623+		const button = element("button", "edit-btn mono", "edit");624+		button.type = "button";625+		(section.querySelector(".repo-details-head") ?? section).append(button);626+		button.addEventListener("click", (event) => {627+			event.preventDefault();628+			if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]);629+		});630+	}631+}632+633+const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;634+const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm";635+636+function editText(base, form, field, body, text) {637+	const kept = [...body.childNodes];638+	const input = element("textarea", "editor-input mono");639+	input.value = text;640+	input.rows = field === "details" ? 16 : 3;641+	const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`);642+	const media = element("div", "editor-media mono");643+	const status = element("span", "editor-status");644+	const post = (path, fields) => {645+		const data = new FormData();646+		data.append("form", form);647+		for (const [name, value] of Object.entries(fields)) data.append(name, value);648+		return fetch(`${base}/${path}`, { method: "POST", body: data });649+	};650+	let timer = null;651+	const changed = () => {652+		inspect();653+		clearTimeout(timer);654+		timer = setTimeout(async () => {655+			const answer = await post("preview", { field, value: input.value }).catch(() => null);656+			if (!answer?.ok) return;657+			// Kepr's own Markdown rendering, raw HTML off: the same markup the page will carry.658+			preview.innerHTML = input.value.trim() ? await answer.text() : "";659+			preview.dataset.empty = input.value.trim() ? "" : "the README's words show here";660+		}, 250);661+	};662+	const wrap = (before, after = before, fallback = "text") => {663+		const [from, to] = [input.selectionStart, input.selectionEnd];664+		const chosen = input.value.slice(from, to) || fallback;665+		input.setRangeText(before + chosen + after, from, to);666+		input.setSelectionRange(from + before.length, from + before.length + chosen.length);667+		input.focus();668+		changed();669+	};670+	const prefix = (mark) => {671+		const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1;672+		input.setRangeText(mark, start, start, "end");673+		input.focus();674+		changed();675+	};676+	const upload = async (file) => {677+		status.textContent = `uploading ${file.name}…`;678+		const answer = await post("media", { file }).catch(() => null);679+		if (!answer?.ok) {680+			status.textContent = answer ? await answer.text() : "upload failed";681+			return;682+		}683+		const { url } = await answer.json();684+		const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");685+		input.setRangeText(686+			`\n![${alt}](${url} "width=100 center")\n`,687+			input.selectionStart,688+			input.selectionEnd,689+			"end",690+		);691+		status.textContent = "";692+		input.focus();693+		changed();694+	};695+	const picker = element("input", "");696+	picker.type = "file";697+	picker.accept = MEDIA_TYPES;698+	picker.hidden = true;699+	picker.addEventListener("change", () => {700+		for (const file of picker.files) upload(file);701+		picker.value = "";702+	});703+	// The picture or video under the caret, with its options, as `![alt](url "options")`.704+	const underCaret = () =>705+		[...input.value.matchAll(MEDIA_MARKDOWN)].find(706+			(m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length,707+		);708+	function inspect() {709+		const found = field === "details" ? underCaret() : undefined;710+		media.hidden = !found;711+		if (!found) return;712+		const words = (found[3] ?? "").split(/\s+/).filter(Boolean);713+		const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100";714+		const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";715+		const loop = words.includes("loop");716+		const video = /\.(mp4|webm)$/i.test(found[2]);717+		const set = (next) => {718+			const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean);719+			const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`;720+			const caret = input.selectionStart;721+			input.setRangeText(markdown, found.index, found.index + found[0].length);722+			input.setSelectionRange(caret, caret);723+			changed();724+		};725+		const choice = (label, active, action) => {726+			const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label);727+			b.type = "button";728+			b.addEventListener("click", action);729+			return b;730+		};731+		media.replaceChildren(732+			element("span", "editor-label", video ? "video" : "picture"),733+			...["25", "33", "50", "66", "75", "100"].map((w) =>734+				choice(`${w}%`, w === width, () => set({ width: w, align, loop })),735+			),736+			...["left", "center", "right"].map((a) =>737+				choice(a, a === align, () => set({ width, align: a, loop })),738+			),739+			...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []),740+		);741+	}742+	const save = async (value) => {743+		status.textContent = "saving…";744+		const answer = await post("text", { field, value }).catch(() => null);745+		if (answer?.ok) location.reload();746+		else status.textContent = answer ? await answer.text() : "not saved: offline?";747+	};748+	const cancel = () => body.replaceChildren(...kept);749+	const tool = (label, title, action) => {750+		const b = element("button", "fv-ctrl-btn", label);751+		b.type = "button";752+		b.title = title;753+		b.addEventListener("click", action);754+		return b;755+	};756+	const toolbar = element("div", "editor-toolbar mono");757+	toolbar.append(758+		tool("B", "bold (⌘B)", () => wrap("**")),759+		tool("I", "italic (⌘I)", () => wrap("_")),760+		tool("link", "link (⌘K)", () => wrap("[", "](https://)")),761+		tool("code", "code", () => wrap("`")),762+	);763+	if (field === "details") {764+		toolbar.append(765+			tool("heading", "heading", () => prefix("## ")),766+			tool("list", "list", () => prefix("- ")),767+			tool("quote", "quote", () => prefix("> ")),768+			tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () =>769+				picker.click(),770+			),771+			picker,772+		);773+		input.addEventListener("dragover", (event) => event.preventDefault());774+		input.addEventListener("drop", (event) => {775+			if (event.dataTransfer.files.length === 0) return;776+			event.preventDefault();777+			for (const file of event.dataTransfer.files) upload(file);778+		});779+		input.addEventListener("paste", (event) => {780+			const files = [...event.clipboardData.files];781+			if (files.length === 0) return;782+			event.preventDefault();783+			for (const file of files) upload(file);784+		});785+	}786+	input.addEventListener("input", changed);787+	for (const name of ["click", "keyup"]) input.addEventListener(name, inspect);788+	input.addEventListener("keydown", (event) => {789+		const command = event.metaKey || event.ctrlKey;790+		if (event.key === "Escape") cancel();791+		else if (command && event.key === "Enter") save(input.value);792+		else if (command && event.key === "b") wrap("**");793+		else if (command && event.key === "i") wrap("_");794+		else if (command && event.key === "k") wrap("[", "](https://)");795+		else return;796+		event.preventDefault();797+	});798+	const actions = element("div", "editor-actions mono");799+	actions.append(800+		tool("save", "save (⌘↵)", () => save(input.value)),801+		tool("cancel", "cancel (esc)", cancel),802+		tool("use the README", "clear your words and show the README's again", () => save("")),803+		status,804+	);805+	const panes = element("div", "editor-panes");806+	panes.append(input, preview);807+	const editor = element("div", `editor editor-${field}`);808+	editor.append(toolbar, panes, media, actions);809+	body.replaceChildren(editor);810+	input.focus();811+	changed();812+}813+605814 // Storage can be missing, refused (private windows, blocked windows) or hold anything; only a606815 // list of folder paths is trusted, and otherwise the tree starts closed.607816 function remembered(key) {@@ -657,6 +866,7 @@657866 panels();658867 saveLists();659868 atlasPicker();869+repoEditor();660870 lineRanges();661871 atlas();662872 hexGlow();

view file

src/admin/text.ts+110 −0
@@ -0,0 +1,110 @@1+import { createHash } from "node:crypto";2+import { mkdirSync } from "node:fs";3+import { join } from "node:path";4+import type { Hono } from "hono";5+import { describeFile } from "../describe/describe.ts";6+import { openMirror } from "../mirror/store.ts";7+import { renderMarkdown, renderParagraph } from "../pages/markdown.ts";8+import { readmeAtHead } from "../pages/repo.ts";9+import { publicRepo, type RepoText, repoText, setRepoText } from "../server/db.ts";10+import { formToken } from "../server/login.ts";11+import type { AdminContext } from "./console.ts";12+import { textFields } from "./console.ts";13+import { findRepo } from "./store.ts";14+15+const LIMITS: Record<RepoText, number> = { description: 2000, details: 60_000 };16+const MEDIA_ROUTE = "/static/assets/media/:repo/:name{[0-9a-f]{64}\\.(png|jpg|gif|webp|mp4|webm)}";17+18+// What an upload is, from its first bytes rather than its name or declared type.19+const MEDIA: [string, (bytes: Uint8Array) => boolean][] = [20+	["png", (b) => ascii(b, 1, 4) === "PNG" && b[0] === 0x89],21+	["jpg", (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff],22+	["gif", (b) => ascii(b, 0, 4) === "GIF8"],23+	["webp", (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP"],24+	["mp4", (b) => ascii(b, 4, 8) === "ftyp"],25+	["webm", (b) => b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3],26+];27+28+function ascii(bytes: Uint8Array, from: number, to: number): string {29+	return String.fromCharCode(...bytes.subarray(from, to));30+}31+32+/**33+ * Editing a repo's description and details from its own page while signed in: the texts (with34+ * the README's as a starting point), a preview as the page will render it, saving, and pictures35+ * and videos for the details, kept in `data/assets/media/<repo>/` so backups carry them.36+ */37+export function registerRepoText(app: Hono, ctx: AdminContext): void {38+	app.get("/admin/repos/:repo/text", (c) => {39+		const repo = findRepo(ctx.kepr, c.req.param("repo"));40+		if (!repo) return c.notFound();41+		const readme = readmeAtHead(openMirror(ctx.mirrorDir, repo.name));42+		const own = repoText(ctx.kepr, repo.name);43+		return c.json({44+			form: formToken(c),45+			description: own.description || (readme && describeFile("README.md", readme)) || "",46+			details: own.details || readme || "",47+		});48+	});49+50+	app.post("/admin/repos/:repo/text", async (c) => {51+		const repo = findRepo(ctx.kepr, c.req.param("repo"));52+		if (!repo) return c.notFound();53+		const { field, value } = await textFields(c);54+		if (field !== "description" && field !== "details") return c.text("Unknown field.", 400);55+		const text = (value ?? "").trim();56+		if (text.length > LIMITS[field]) {57+			return c.text(58+				`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`,59+				400,60+			);61+		}62+		setRepoText(ctx.kepr, repo.name, field, text);63+		return c.body(null, 204);64+	});65+66+	app.post("/admin/repos/:repo/preview", async (c) => {67+		const repo = findRepo(ctx.kepr, c.req.param("repo"));68+		if (!repo) return c.notFound();69+		const { field, value } = await textFields(c);70+		const base = { repo: repo.name, dir: "", at: null, own: true };71+		const text = value ?? "";72+		return c.html(73+			field === "description"74+				? renderParagraph(text, base).html.value75+				: renderMarkdown(text, base).value,76+		);77+	});78+79+	// Named by their hash, so cached long; `private`, so no shared cache keeps one after the repo80+	// turns private (§20). Ranges are answered, which video players need.81+	app.get(MEDIA_ROUTE, async (c) => {82+		const repo = c.req.param("repo");83+		const file = Bun.file(join(ctx.dataDir, "assets", "media", repo, c.req.param("name")));84+		if (!publicRepo(ctx.kepr, repo) || !(await file.exists())) return c.notFound();85+		const headers = { "Content-Type": file.type, "Cache-Control": "private, max-age=86400" };86+		c.header("Accept-Ranges", "bytes");87+		const [, from = "", to = ""] = /^bytes=(\d*)-(\d*)$/.exec(c.req.header("range") ?? "") ?? [];88+		if (from === "" && to === "") return c.body(await file.bytes(), 200, headers);89+		const start = from === "" ? Math.max(0, file.size - Number(to)) : Number(from);90+		const end = from === "" || to === "" ? file.size - 1 : Math.min(Number(to), file.size - 1);91+		if (start > end) return c.body(null, 416, { "Content-Range": `bytes */${file.size}` });92+		const part = await file.slice(start, end + 1).bytes();93+		return c.body(part, 206, { ...headers, "Content-Range": `bytes ${start}-${end}/${file.size}` });94+	});95+96+	app.post("/admin/repos/:repo/media", async (c) => {97+		const repo = findRepo(ctx.kepr, c.req.param("repo"));98+		if (!repo) return c.notFound();99+		const file = (await c.req.parseBody()).file;100+		if (!(file instanceof File)) return c.text("Choose a file to upload.", 400);101+		const bytes = new Uint8Array(await file.arrayBuffer());102+		const kind = MEDIA.find(([, is]) => is(bytes))?.[0];103+		if (!kind) return c.text("Only PNG, JPEG, GIF, WebP, MP4 and WebM files.", 400);104+		const name = `${createHash("sha256").update(bytes).digest("hex")}.${kind}`;105+		const dir = join(ctx.dataDir, "assets", "media", repo.name);106+		mkdirSync(dir, { recursive: true });107+		await Bun.write(join(dir, name), bytes);108+		return c.json({ url: `/static/assets/media/${repo.name}/${name}` });109+	});110+}

view file

src/pages/layout.ts+1 −1
@@ -76,7 +76,7 @@7676 		site7777 	}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>7878 ${branding && html`<span class="wordmark-by">${branding}</span>`}79-<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in">log in</a></span>79+<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in?next=${encodeURIComponent(options.path).replaceAll("%2F", "/")}">log in</a></span>8080 </nav>8181 <main>8282 ${options.body}

view file

src/pages/library.ts+2 −2
@@ -2,7 +2,7 @@22 import { publicRepoNames } from "../server/db.ts";33 import { type Html, html } from "./html.ts";44 import { openPublicRepo, type PageContext, type PublicRepo, plural } from "./layout.ts";5-import { readmeAtHead, readmeIntro } from "./repo.ts";5+import { repoIntro } from "./repo.ts";66 77 /** A public repo as the front page, browse and the profile show it. `saves` are oldest first. */88 export type LibraryRepo = PublicRepo & { stats: RepoStats; description: string; saves: Save[] };@@ -14,7 +14,7 @@1414 	return publicRepoNames(ctx.kepr).flatMap((name) => {1515 		const repo = openPublicRepo(ctx, name);1616 		if (!repo) return [];17-		const description = readmeIntro(name, readmeAtHead(repo))?.text ?? "";17+		const description = repoIntro(repo)?.text ?? "";1818 		return [{ ...repo, stats: repoStats(repo.mirror), description, saves: timeline(repo.mirror) }];1919 	});2020 }

view file

src/pages/markdown.ts+40 −2
@@ -2,9 +2,17 @@22 import { type Html, trusted } from "./html.ts";33 import { pathUrl } from "./layout.ts";44 5-/** Where a Markdown file lives, so its relative links and images can point at the repo's raw files. */6-export type MarkdownBase = { repo: string; dir: string; at: string | null };5+/**6+ * Where a Markdown file lives, so its relative links and images can point at the repo's raw files.7+ * `own` marks the operator's words, whose `/static/assets/media/` uploads stay as written.8+ */9+export type MarkdownBase = { repo: string; dir: string; at: string | null; own?: boolean };710 11+const MEDIA_PREFIX = "/static/assets/media/";12+const VIDEO = /\.(mp4|webm)$/i;13+const WIDTHS = [25, 33, 50, 66, 75, 100];14+const OPTION = /^(width=\d{1,3}|left|center|right|loop)$/;15+816 const SCHEME = /^([a-z][a-z0-9+.-]*):/i;917 const ALLOWED_SCHEMES = ["http", "https", "mailto"];1018 @@ -31,11 +39,41 @@3139 3240 function rawUrl(url: string, base: MarkdownBase): string {3341 	if (SCHEME.test(url) || url.startsWith("#") || url.startsWith("//")) return url;42+	if (base.own && url.startsWith(MEDIA_PREFIX)) return url;3443 	// A leading `/` means the repo root, as it does on a code host.3544 	const resolved = new URL(url, `http://repo/${base.dir ? `${pathUrl(base.dir)}/` : ""}`);3645 	const at = base.at ? `?at=${base.at}` : "";3746 	return `/${base.repo}/raw${resolved.pathname}${at}${resolved.hash}`;3847 }48+49+// A title made only of options sizes and places a picture or video: `![demo](x.mp4 "width=5050+// left loop")`. They become classes, since the page's policy allows no inline style. A video51+// plays with controls, or, with `loop`, silently on repeat like a GIF.52+const image = md.renderer.rules.image;53+md.renderer.rules.image = (tokens, idx, options, env, self) => {54+	const token = tokens[idx] as (typeof tokens)[number];55+	const words = String(token.attrGet("title") ?? "")56+		.split(/\s+/)57+		.filter(Boolean);58+	const styled = words.length > 0 && words.every((word) => OPTION.test(word));59+	const src = String(token.attrGet("src") ?? "");60+	const video = VIDEO.test(src.split(/[?#]/)[0] as string);61+	if (!styled && !video) return image ? image(tokens, idx, options, env, self) : "";62+	const asked = Number(words.find((word) => word.startsWith("width="))?.slice(6) ?? 100);63+	const width = WIDTHS.find((w) => w >= asked) ?? 100;64+	const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";65+	const classes = `media media-w${width} media-${align}`;66+	if (!video) {67+		token.attrs = (token.attrs ?? []).filter(([name]) => name !== "title");68+		token.attrSet("class", classes);69+		return image ? image(tokens, idx, options, env, self) : "";70+	}71+	const alt = md.utils.escapeHtml(token.content);72+	const play = words.includes("loop")73+		? "autoplay muted loop playsinline"74+		: 'controls preload="metadata"';75+	return `<video class="${classes}" src="${md.utils.escapeHtml(src)}" ${play} aria-label="${alt}"></video>`;76+};3977 4078 export function renderMarkdown(source: string, base: MarkdownBase): Html {4179 	return trusted(md.render(source, base));

view file

src/pages/repo.ts+30 −18
@@ -46,9 +46,8 @@4646 		const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);4747 		if (at !== undefined && !atSave) return notFound(c, ctx);4848 		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);49-		const readme = readmeAtHead(repo);5049 		const name = repo.settings.name;51-		const intro = readmeIntro(name, readme);50+		const intro = repoIntro(repo);5251 		cacheFor(c, false);5352 		return c.html(5453 			page(ctx, {@@ -57,27 +56,39 @@5756 				path: `/${name}`,5857 				body: html`${header(ctx, repo, intro?.html ?? null)}5958 ${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}60-${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,59+${details(repoDetails(repo))}`,6160 			}),6261 		);6362 	});6463 }6564 66-/** The README's first paragraph (§21.4's description), as markup and as plain text. */67-export function readmeIntro(68-	repo: string,69-	readme: string | null,70-): { html: Html; text: string } | null {65+/**66+ * The repo's description, as markup and as plain text: the operator's own, or else the README's67+ * first paragraph (§21.4).68+ */69+export function repoIntro(repo: PublicRepo): { html: Html; text: string } | null {70+	const { name, description } = repo.settings;71+	if (description)72+		return renderParagraph(description, { repo: name, dir: "", at: null, own: true });73+	const readme = readmeAtHead(repo.mirror);7174 	const paragraph = readme === null ? null : describeFile("README.md", readme);72-	return paragraph === null ? null : renderParagraph(paragraph, { repo, dir: "", at: null });75+	return paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });7376 }7477 75-export function readmeAtHead(repo: PublicRepo): string | null {76-	const head = headId(repo.mirror);78+/** The details section: the operator's own Markdown, or else the rendered README. */79+export function repoDetails(repo: PublicRepo): Html | null {80+	const { name, details } = repo.settings;81+	if (details) return renderMarkdown(details, { repo: name, dir: "", at: null, own: true });82+	const readme = readmeAtHead(repo.mirror);83+	return readme === null ? null : renderMarkdown(readme, { repo: name, dir: "", at: null });84+}85+86+export function readmeAtHead(mirror: PublicRepo["mirror"]): string | null {87+	const head = headId(mirror);7788 	const path =78-		head && filesAt(repo.mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;79-	const file = head && path ? fileAt(repo.mirror, head, path) : null;80-	const bytes = file && objectBytes(repo.mirror, file.hash);89+		head && filesAt(mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;90+	const file = head && path ? fileAt(mirror, head, path) : null;91+	const bytes = file && objectBytes(mirror, file.hash);8192 	return bytes ? new TextDecoder().decode(bytes) : null;8293 }8394 @@ -97,7 +108,7 @@97108 	return html`<header class="repo-head">98109 <h1 class="repo-name">${name}</h1>99110 ${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}100-${intro && html`<div class="repo-desc"><p>${intro}</p></div>`}111+${html`<div class="repo-desc"${!intro && " hidden"}><p>${intro}</p></div>`}101112 ${102113 	HTTP_URL.test(homepage) &&103114 	html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`@@ -175,11 +186,12 @@175186 </div>`;176187 }177188 178-function details(readme: Html): Html {179-	return html`<section class="repo-details">189+// Empty and hidden when there is nothing to show, so a signed-in editor still has a place to write.190+function details(body: Html | null): Html {191+	return html`<section class="repo-details"${!body && " hidden"}>180192 <details class="repo-details-toggle" open>181193 <summary class="repo-details-head">details</summary>182-<div class="repo-details-body markdown">${readme}</div>194+<div class="repo-details-body markdown">${body}</div>183195 </details>184196 </section>`;185197 }

view file

src/releases/routes.ts+2 −2
@@ -5,7 +5,7 @@55 import { isPlatform, isVersion, type ReleaseFile } from "../bucket/repo.ts";66 import { headId, repoStats, saveByPrefix } from "../mirror/queries.ts";77 import { notFound, openPublicRepo, type PageContext, type PublicRepo } from "../pages/layout.ts";8-import { readmeAtHead, readmeIntro } from "../pages/repo.ts";8+import { repoIntro } from "../pages/repo.ts";99 import { checksumsTxt, historyJson, metaJson, releaseJson, updateInfoJson } from "./feeds.ts";1010 import { countDownload, type Release, releaseContentType, visibleReleases } from "./releases.ts";1111 @@ -162,7 +162,7 @@162162 			lineCount: stats.lines,163163 			lastSaveAt: stats.updated ?? 0,164164 			latestRelease: f.releases[0]?.version ?? null,165-			description: readmeIntro(name, readmeAtHead(f.repo))?.text ?? null,165+			description: repoIntro(f.repo)?.text ?? null,166166 			language: stats.language || null,167167 			category: category || null,168168 		};

view file

src/server/app.ts+13 −5
@@ -8,6 +8,7 @@88 import { registerReleases } from "../admin/releases.ts";99 import { registerRepos } from "../admin/repos.ts";1010 import { registerSite } from "../admin/site.ts";11+import { registerRepoText } from "../admin/text.ts";1112 import { openBucket } from "../bucket/reader.ts";1213 import { isSafePath } from "../bucket/repo.ts";1314 import { registerIssuePages } from "../issues/pages.ts";@@ -29,6 +30,9 @@2930 import { registerLogin } from "./login.ts";3031 3132 const MAX_BODY_BYTES = 64 * 1024;33+// §25's cap, except for the operator's picture and video uploads, which only a session reaches.34+const MAX_MEDIA_BYTES = 64 * 1024 * 1024;35+const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/;3236 const PUBLIC_DIR = join(import.meta.dir, "../../public");3337 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.3438 const FONTS = "https://fonts.bunny.net";@@ -63,22 +67,25 @@6367 			xFrameOptions: "DENY",6468 		}),6569 	);66-	app.use(70+	const limit = (maxSize: number) =>6771 		bodyLimit({68-			maxSize: MAX_BODY_BYTES,72+			maxSize,6973 			onError: (c) =>7074 				// An oversized redemption is one more failed redemption — but only where the route7175 				// exists: without write keys it is an unknown path and answers as one.7276 				c.req.path === "/api/connect" && connectKeys(config)7377 					? noCode(c)7478 					: c.text("Request body too large", 413),75-		}),76-	);79+		});80+	const [small, large] = [limit(MAX_BODY_BYTES), limit(MAX_MEDIA_BYTES)];81+	app.use((c, next) => (MEDIA_UPLOAD.test(c.req.path) ? large : small)(c, next));7782 	// `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature.7883 	const assetsDir = join(config.dataDir, "assets");79-	app.get("/static/:path{.+}", async (c) => {84+	app.get("/static/:path{.+}", async (c, next) => {8085 		const path = c.req.param("path");8186 		if (!isSafePath(path)) return c.notFound();87+		// A repo's uploads have their own route: as private as the repo (§20), and Range for video.88+		if (path.startsWith("assets/media/")) return next();8289 		const file = Bun.file(8390 			path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path),8491 		);@@ -102,6 +109,7 @@102109 	registerIssues(app, admin);103110 	registerReleases(app, admin);104111 	registerSite(app, admin);112+	registerRepoText(app, admin);105113 	registerHousekeeping(app, ctx);106114 	registerFrontPages(app, ctx);107115 	registerDocsPages(app, ctx);

view file

src/server/db.ts+42 −3
@@ -76,6 +76,12 @@7676 	token_hash TEXT PRIMARY KEY,7777 	expires_at INTEGER NOT NULL7878 );79+-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.80+CREATE TABLE IF NOT EXISTS repo_text (81+	repo        TEXT PRIMARY KEY,82+	description TEXT NOT NULL DEFAULT '',83+	details     TEXT NOT NULL DEFAULT ''84+);7985 CREATE TABLE IF NOT EXISTS connect_codes (8086 	code_hash   TEXT PRIMARY KEY,8187 	machine     TEXT NOT NULL,@@ -85,13 +91,18 @@8591 );8692 `;8793 94+/** `description` and `details` are the operator's Markdown, or '' for the README's. */8895 export type RepoSettings = {8996 	name: string;9097 	category: string;9198 	homepage: string;9299 	installLines: string[];100+	description: string;101+	details: string;93102 };94103 104+export type RepoText = "description" | "details";105+95106 type RepoRow = {96107 	name: string;97108 	category: string;@@ -99,6 +110,8 @@99110 	install1: string;100111 	install2: string;101112 	install3: string;113+	description: string | null;114+	details: string | null;102115 };103116 104117 export function openKeprDb(dataDir: string): Database {@@ -143,11 +156,37 @@143156 export function publicRepo(db: Database, name: string): RepoSettings | null {144157 	const row = db145158 		.query<RepoRow, [string]>(146-			"SELECT name, category, homepage, install1, install2, install3 " +147-				"FROM repos WHERE name = ? AND public = 1",159+			`SELECT name, category, homepage, install1, install2, install3, description, details160+			FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name161+			WHERE name = ? AND public = 1`,148162 		)149163 		.get(name);150164 	if (!row) return null;151165 	const installLines = [row.install1, row.install2, row.install3].filter((line) => line !== "");152-	return { name: row.name, category: row.category, homepage: row.homepage, installLines };166+	return {167+		name: row.name,168+		category: row.category,169+		homepage: row.homepage,170+		installLines,171+		description: row.description ?? "",172+		details: row.details ?? "",173+	};174+}175+176+/** A repo's own texts, public or not; '' where the README speaks. */177+export function repoText(db: Database, repo: string): Record<RepoText, string> {178+	return (179+		db180+			.query<Record<RepoText, string>, [string]>(181+				"SELECT description, details FROM repo_text WHERE repo = ?",182+			)183+			.get(repo) ?? { description: "", details: "" }184+	);185+}186+187+/** Sets one of a repo's texts; '' hands it back to the README. */188+export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {189+	db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);190+	// `field` is one of two column names, never input.191+	db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);153192 }

view file

src/server/login.ts+5 −0
@@ -8,6 +8,9 @@88 const SESSION_SECONDS = 30 * 86400;99 const SESSION_COOKIE = "kepr_session";1010 const SIGN_IN_COOKIE = "kepr_sign_in";11+// Readable by kepr.js, and saying only that someone signed in here: public pages stay the same12+// cacheable HTML for everyone, and the script asks the admin routes for the editing controls.13+const EDITOR_COOKIE = "kepr_editor";1114 const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const;1215 const HEX64 = /^[0-9a-f]{64}$/;1316 const FAILURE_WINDOW_MS = 15 * 60 * 1000;@@ -123,6 +126,7 @@123126 			.query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")124127 			.run(sha256(token), at + SESSION_SECONDS);125128 		setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });129+		setCookie(c, EDITOR_COOKIE, "1", { ...COOKIE, httpOnly: false, maxAge: SESSION_SECONDS });126130 		deleteCookie(c, SIGN_IN_COOKIE, COOKIE);127131 		return c.redirect(next, 303);128132 	});@@ -132,6 +136,7 @@132136 			.query("DELETE FROM sessions WHERE token_hash = ?")133137 			.run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));134138 		deleteCookie(c, SESSION_COOKIE, COOKIE);139+		deleteCookie(c, EDITOR_COOKIE, { ...COOKIE, httpOnly: false });135140 		return c.redirect("/", 303);136141 	});137142 }

view file

tests/front.test.ts+1 −1
@@ -133,7 +133,7 @@133133 	test("the nav has browse, docs, support and log in, as Kepr 2's", async () => {134134 		const html = await body("/");135135 		expect(html).toContain(136-			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in">log in</a>',136+			'<a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a><a href="/sign-in?next=/">log in</a>',137137 		);138138 	});139139 

view file

tests/repotext.test.ts+125 −0
@@ -0,0 +1,125 @@1+import { beforeAll, describe, expect, test } from "bun:test";2+import { Browser, type Instance, instance } from "./browser.ts";3+import { makeRepos } from "./fixture.ts";4+5+const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);6+const MP4 = new Uint8Array([0, 0, 0, 0x18, ...new TextEncoder().encode("ftypisom"), 1, 2, 3, 4]);7+8+describe("editing a repo's description and details while signed in", () => {9+	let inst: Instance;10+	let browser: Browser;11+	const page = async (path: string) => (await inst.app.request(path)).text();12+	const save = (field: string, value: string) =>13+		browser.post("/admin/repos/app/text", { field, value });14+	const upload = (bytes: Uint8Array, name: string) => {15+		const data = new FormData();16+		data.append("file", new File([bytes], name));17+		return browser.post("/admin/repos/app/media", data);18+	};19+20+	beforeAll(async () => {21+		const repos = await makeRepos(["app", "shy"]);22+		inst = await instance(repos.bucketDir);23+		inst.kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");24+		browser = new Browser(inst.app);25+	});26+27+	test("signing in leaves a script-readable mark that says only that, beside the session", async () => {28+		const response = await browser.signIn();29+		const cookies = response.headers.getSetCookie();30+		expect(cookies.find((line) => line.startsWith("kepr_editor=1"))).not.toMatch(/HttpOnly/i);31+		expect(cookies.find((line) => line.startsWith("kepr_session="))).toMatch(/HttpOnly/i);32+		// The public page is the same for everyone, signed in or not.33+		expect(await (await browser.request("/app")).text()).toBe(await page("/app"));34+	});35+36+	test("starts from the README's words", async () => {37+		const response = await browser.request("/admin/repos/app/text");38+		const texts = (await response.json()) as Record<"form" | "description" | "details", string>;39+		expect(texts.description).toBe("The app project.");40+		expect(texts.details).toContain("# app");41+		// The same token the console's forms carry, so the editor's posts pass the guard.42+		expect(texts.form).toBe(browser.form);43+	});44+45+	test("the operator's description replaces the README's on the page, in cards and previews", async () => {46+		expect((await save("description", "My **own** words.")).status).toBe(204);47+		const repo = await page("/app");48+		expect(repo).toContain('<div class="repo-desc"><p>My <strong>own</strong> words.</p></div>');49+		expect(repo).toContain('<meta name="description" content="My own words.">');50+		expect(await page("/browse")).toContain('<p class="repo-card-desc">My own words.</p>');51+	});52+53+	test("details size and place pictures and videos from their options, and keep uploads' URLs", async () => {54+		const details = [55+			'![shot](/static/assets/media/app/a.png "width=30 right")',56+			'![demo](/static/assets/media/app/b.mp4 "width=50 left loop")',57+			"![talk](/static/assets/media/app/c.webm)",58+			'![plain](/pic.png "a real title")',59+		].join("\n\n");60+		expect((await save("details", details)).status).toBe(204);61+		const repo = await page("/app");62+		expect(repo).toContain(63+			'<img src="/static/assets/media/app/a.png" alt="shot" class="media media-w33 media-right">',64+		);65+		expect(repo).toContain(66+			'<video class="media media-w50 media-left" src="/static/assets/media/app/b.mp4" autoplay muted loop playsinline aria-label="demo"></video>',67+		);68+		expect(repo).toContain(69+			'<video class="media media-w100 media-center" src="/static/assets/media/app/c.webm" controls preload="metadata" aria-label="talk"></video>',70+		);71+		// A title that is not options stays a title, and a root path is still the repo's own file.72+		expect(repo).toContain('<img src="/app/raw/pic.png" alt="plain" title="a real title">');73+		expect(repo).not.toContain("# app");74+	});75+76+	test("the preview renders as the page will", async () => {77+		const response = await browser.post("/admin/repos/app/preview", {78+			field: "details",79+			value: "**hi** <script>x</script>",80+		});81+		expect(await response.text()).toBe(82+			"<p><strong>hi</strong> &lt;script&gt;x&lt;/script&gt;</p>\n",83+		);84+	});85+86+	test("clearing a text brings the README's back", async () => {87+		await save("description", "");88+		await save("details", "");89+		const repo = await page("/app");90+		expect(repo).toContain("<p>The app project.</p>");91+		expect(repo).toContain("<h1>app</h1>");92+	});93+94+	test("refuses an unknown field, an overlong text and an unknown repo", async () => {95+		expect((await save("title", "x")).status).toBe(400);96+		expect((await save("description", "x".repeat(2001))).status).toBe(400);97+		expect((await browser.post("/admin/repos/nope/text", { field: "details" })).status).toBe(404);98+	});99+100+	test("uploads pictures and videos by what their bytes are, served as privately as the repo", async () => {101+		const png = await upload(PNG, "shot.txt");102+		expect(png.status).toBe(200);103+		const { url } = (await png.json()) as { url: string };104+		expect(url).toMatch(/^\/static\/assets\/media\/app\/[0-9a-f]{64}\.png$/);105+		const served = await inst.app.request(url);106+		expect([served.status, served.headers.get("content-type")]).toEqual([200, "image/png"]);107+		const video = ((await (await upload(MP4, "demo.mp4")).json()) as { url: string }).url;108+		expect(video).toEndWith(".mp4");109+		// Bun.serve answers Range for a file response, which Safari needs to play a video.110+		const server = Bun.serve({ port: 0, fetch: (request) => inst.app.fetch(request) });111+		const range = await fetch(new URL(video, server.url), { headers: { range: "bytes=0-3" } });112+		expect([range.status, (await range.arrayBuffer()).byteLength]).toEqual([206, 4]);113+		server.stop();114+		expect((await upload(new TextEncoder().encode("<svg onload=x>"), "a.svg")).status).toBe(400);115+		inst.kepr.run("UPDATE repos SET public = 0 WHERE name = 'app'");116+		expect((await inst.app.request(url)).status).toBe(404);117+		inst.kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");118+	});119+120+	test("signed out, none of it answers", async () => {121+		const stranger = new Browser(inst.app);122+		expect((await stranger.request("/admin/repos/app/text")).status).toBe(401);123+		expect((await stranger.post("/admin/repos/app/media", new FormData())).status).toBe(401);124+	});125+});

view file