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
↓ kepr-4030bdca.tar.gzatlas at this save
files changed14 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 −0package.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": {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) -------------------------------- */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\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 ``.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();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+}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}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 }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: ``. 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));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 }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 };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);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 }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 }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 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+ '',56+ '',57+ "",58+ '',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> <script>x</script></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+});