10a0fccd0ed8

make the support page the operator's own Markdown, edited and switched off in the console, and give the report form a cancel link (v3.0.110)

time
2026-09-29 19:33 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
editable support page
parent
1d43f75aee52
7 files package.json+1 −1public/css/kepr.css+13 −0src/admin/site.ts+16 −4src/issues/pages.ts+2 −1src/pages/docs.ts+34 −13src/pages/layout.ts+1 −1tests/console.test.ts+18 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.109",3+	"version": "3.0.110",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+13 −0
@@ -1472,6 +1472,19 @@14721472 .issues-submit:hover {14731473 	opacity: 0.85;14741474 }1475+.issues-actions {1476+	display: flex;1477+	align-items: baseline;1478+	gap: 16px;1479+	margin: 0;1480+}1481+.issues-cancel {1482+	font-size: 0.75rem;1483+	color: var(--text-muted);1484+}1485+.issues-cancel:hover {1486+	color: var(--accent);1487+}14751488 .issues-error {14761489 	font-size: 0.75rem;14771490 	color: #c86a5a;

view file

src/admin/site.ts+16 −4
@@ -2,6 +2,7 @@22 import { mkdirSync, rmSync, writeFileSync } from "node:fs";33 import { join } from "node:path";44 import type { Context, Hono } from "hono";5+import { supportText } from "../pages/docs.ts";56 import { html } from "../pages/html.ts";67 import { setting } from "../server/db.ts";78 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";@@ -17,7 +18,9 @@1718 	["location", "location", 100],1819 	["link", "link", 500],1920 	["building_since", "building since", 100],21+	["support_page", "support page (Markdown)", 4000],2022 ];23+const TEXTAREAS = new Set(["bio", "support_page"]);2124 const HANDLE = /^[A-Za-z0-9._-]+$/;2225 const HTTP_URL = /^https?:\/\/[^\s]+$/i;2326 // Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.@@ -52,6 +55,8 @@5255 		if (values.handle && !HANDLE.test(values.handle)) {5356 			return refuse("A handle is letters, digits, '.', '_' and '-'.");5457 		}58+		// Shown by default: the setting records only that it was switched off.59+		values.support_off = fields.support === "1" ? "" : "1";5560 		if (values.link && !HTTP_URL.test(values.link)) {5661 			return refuse("The link must be an http or https address.");5762 		}@@ -88,19 +93,26 @@8893 8994 function sitePage(c: Context, ctx: AdminContext, error: string): string {9095 	const signature = setting(ctx.kepr, "signature");96+	// The support page's box starts from the words it shows today.97+	const value = (key: string) =>98+		key === "support_page" ? supportText(ctx.kepr) : (setting(ctx.kepr, key) ?? "");9199 	const input = ([key, label, max]: (typeof FIELDS)[number]) =>92100 		html`<label class="issues-label" for="${key}">${label}</label>93101 ${94-	key === "bio"95-		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="4"96-maxlength="${max}">${setting(ctx.kepr, key) ?? ""}</textarea>`102+	TEXTAREAS.has(key)103+		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="${key === "bio" ? 4 : 12}"104+maxlength="${max}">${value(key)}</textarea>`97105 		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"98-value="${setting(ctx.kepr, key) ?? ""}">`106+value="${value(key)}">`99107 }`;100108 	const body = html`${notice(error)}101109 <form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">102110 ${formField(c)}103111 ${FIELDS.map(input)}112+<label class="issues-label"><input type="checkbox" name="support" value="1"${113+		!setting(ctx.kepr, "support_off") && " checked"114+	}>115+show the support page</label>104116 <label class="issues-label" for="signature">signature image105117 <span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>106118 ${signature && html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="current signature">`}

view file

src/issues/pages.ts+2 −1
@@ -238,7 +238,8 @@238238 <label class="issues-label" for="issue-email">contact email239239 <span class="muted">(optional, only the operator sees it)</span></label>240240 <input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">241-<button class="issues-submit" type="submit">file issue</button>241+<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>242+<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>242243 </form>`;243244 	return c.html(244245 		page(ctx, {

view file

src/pages/docs.ts+34 −13
@@ -1,23 +1,44 @@1+import type { Database } from "bun:sqlite";12 import { readFileSync } from "node:fs";23 import { join } from "node:path";34 import type { Hono } from "hono";45 import { describeFile } from "../describe/describe.ts";5-import { html } from "./html.ts";6-import { cacheFor, type PageContext, page } from "./layout.ts";6+import { setting } from "../server/db.ts";7+import { type Html, html } from "./html.ts";8+import { cacheFor, notFound, type PageContext, page } from "./layout.ts";79 import { renderParagraph, renderSiteMarkdown } from "./markdown.ts";810 911 const SITE_DIR = join(import.meta.dir, "../../docs/site");1012 11-/** §21.10: `/docs` and `/support` are Markdown files in Kepr's repo, rendered once at startup. */13+/**14+ * §21.10: `/docs` is a Markdown file in Kepr's repo, rendered once at startup. `/support` is the15+ * operator's, edited in the console (June, 2026-09-29): the shipped file is only its first words,16+ * and the page can be switched off, which takes it out of the nav too.17+ */1218 export function registerDocsPages(app: Hono, ctx: PageContext): void {13-	for (const name of ["docs", "support"]) {14-		const source = readFileSync(join(SITE_DIR, `${name}.md`), "utf8");15-		const intro = describeFile(`${name}.md`, source);16-		const description = intro ? renderParagraph(intro, { repo: "", dir: "", at: null }).text : "";17-		const body = html`<article class="site-doc markdown">${renderSiteMarkdown(source)}</article>`;18-		app.get(`/${name}`, (c) => {19-			cacheFor(c, false);20-			return c.html(page(ctx, { title: name, description, path: `/${name}`, body }));21-		});22-	}19+	const docs = readFileSync(join(SITE_DIR, "docs.md"), "utf8");20+	const docsBody = sitePage(docs);21+	app.get("/docs", (c) => {22+		cacheFor(c, false);23+		return c.html(page(ctx, { title: "docs", path: "/docs", ...docsBody }));24+	});25+	app.get("/support", (c) => {26+		if (setting(ctx.kepr, "support_off")) return notFound(c, ctx);27+		cacheFor(c, false);28+		const body = sitePage(supportText(ctx.kepr));29+		return c.html(page(ctx, { title: "support", path: "/support", ...body }));30+	});31+}32+33+/** The support page's Markdown: the operator's, or the shipped words until they write their own. */34+export function supportText(db: Database): string {35+	return setting(db, "support_page") ?? readFileSync(join(SITE_DIR, "support.md"), "utf8");36+}37+38+function sitePage(source: string): { description: string; body: Html } {39+	const intro = describeFile("page.md", source);40+	return {41+		description: intro ? renderParagraph(intro, { repo: "", dir: "", at: null }).text : "",42+		body: html`<article class="site-doc markdown">${renderSiteMarkdown(source)}</article>`,43+	};2344 }

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?next=${encodeURIComponent(options.path).replaceAll("%2F", "/")}">log in</a></span>79+<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a>${!setting(ctx.kepr, "support_off") && html`<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

tests/console.test.ts+18 −0
@@ -328,6 +328,7 @@328328 		location: "Lisbon",329329 		link: "https://ada.example",330330 		building_since: "june 2026",331+		support: "1",331332 	};332333 	const text = async (path: string) => (await inst.app.request(path)).text();333334 	const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {@@ -365,6 +366,23 @@365366 		// Emptying a field removes the setting, so its default comes back.366367 		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);367368 		expect(await text("/")).toContain("<title>kepr</title>");369+	});370+371+	test("the support page is the operator's to write, and to switch off", async () => {372+		// Until written, the shipped words show, and the console's box starts from them.373+		const shipped = await text("/support");374+		expect(await (await operator.get("/admin/site")).text()).toContain("show the support page");375+		const own = { ...settings, support_page: "## Help\n\nMail **me**." };376+		expect((await operator.post("/admin/site", own)).status).toBe(303);377+		const support = await text("/support");378+		expect(support).toContain("<h2>Help</h2>\n<p>Mail <strong>me</strong>.</p>");379+		expect(support).not.toBe(shipped);380+		const { support: _, ...off } = own;381+		expect((await operator.post("/admin/site", off)).status).toBe(303);382+		expect((await inst.app.request("/support")).status).toBe(404);383+		expect(await text("/")).not.toContain('href="/support"');384+		expect((await operator.post("/admin/site", own)).status).toBe(303);385+		expect(await text("/")).toContain('<a href="/support">support</a>');368386 	});369387 370388 	test("a bad handle, link or overlong field is refused", async () => {

view file