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
↓ kepr-10a0fccd.tar.gzatlas at this save
files changed7 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 −0package.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": {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;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">`}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, {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 }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}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 () => {