7120956a7fa3

give the docs page a section sidebar and rewrite it into eight short sections (v3.0.116)

time
2026-09-29 19:57 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
docs page sidebar
parent
97a0a7672807
5 files package.json+1 −1public/css/kepr.css+35 −0src/pages/docs.ts+12 −3src/pages/markdown.ts+14 −3tests/console.test.ts+1 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.115",3+	"version": "3.0.116",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+35 −0
@@ -2048,6 +2048,41 @@20482048 }20492049 .site-doc h2 {20502050 	margin-top: 2.4em;2051+	scroll-margin-top: 24px;2052+}2053+.site-doc-layout {2054+	display: grid;2055+	grid-template-columns: 180px minmax(0, 72ch);2056+	gap: 48px;2057+}2058+.site-doc-nav {2059+	position: sticky;2060+	top: 24px;2061+	align-self: start;2062+	display: flex;2063+	flex-direction: column;2064+	gap: 10px;2065+	margin-top: 32px;2066+	font-size: 0.8125rem;2067+}2068+.site-doc-nav a {2069+	color: var(--text-secondary);2070+	text-decoration: none;2071+}2072+.site-doc-nav a:hover {2073+	color: var(--text-heading);2074+}2075+@media (max-width: 860px) {2076+	.site-doc-layout {2077+		grid-template-columns: minmax(0, 1fr);2078+		gap: 0;2079+	}2080+	.site-doc-nav {2081+		position: static;2082+		flex-flow: row wrap;2083+		gap: 8px 16px;2084+		margin-top: 24px;2085+	}20512086 }20522087 20532088 @media (max-width: 860px) {

view file

src/pages/docs.ts+12 −3
@@ -17,7 +17,7 @@1717  */1818 export function registerDocsPages(app: Hono, ctx: PageContext): void {1919 	const docs = readFileSync(join(SITE_DIR, "docs.md"), "utf8");20-	const docsBody = sitePage(docs);20+	const docsBody = sitePage(docs, true);2121 	app.get("/docs", (c) => {2222 		cacheFor(c, false);2323 		return c.html(page(ctx, { title: "docs", path: "/docs", ...docsBody }));@@ -35,10 +35,19 @@3535 	return setting(db, "support_page") ?? readFileSync(join(SITE_DIR, "support.md"), "utf8");3636 }3737 38-function sitePage(source: string): { description: string; body: Html } {38+/** A site page; with `contents`, a sidebar links each of its sections. */39+function sitePage(source: string, contents = false): { description: string; body: Html } {3940 	const intro = describeFile("page.md", source);41+	const doc = renderSiteMarkdown(source);42+	const article = html`<article class="site-doc markdown">${doc.html}</article>`;43+	const links = doc.sections.map(({ id, title }) => html`<a href="#${id}">${title}</a>`);4044 	return {4145 		description: intro ? renderParagraph(intro, { repo: "", dir: "", at: null }).text : "",42-		body: html`<article class="site-doc markdown">${renderSiteMarkdown(source)}</article>`,46+		body: contents47+			? html`<div class="site-doc-layout">48+<nav class="site-doc-nav mono" aria-label="sections">${links}</nav>49+${article}50+</div>`51+			: article,4352 	};4453 }

view file

src/pages/markdown.ts+14 −3
@@ -79,9 +79,20 @@7979 	return trusted(md.render(source, base));8080 }8181 82-/** One of Kepr's own Markdown pages, such as the docs (§21.10). */83-export function renderSiteMarkdown(source: string): Html {84-	return trusted(md.render(source, {}));82+/** One of Kepr's own Markdown pages, such as the docs (§21.10), with an anchor on each section. */83+export function renderSiteMarkdown(source: string): {84+	html: Html;85+	sections: { id: string; title: string }[];86+} {87+	const tokens = md.parse(source, {});88+	const sections = tokens.flatMap((token, i) => {89+		if (token.type !== "heading_open" || token.tag !== "h2") return [];90+		const title = tokens[i + 1]?.content ?? "";91+		const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");92+		token.attrSet("id", id);93+		return [{ id, title }];94+	});95+	return { html: trusted(md.renderer.render(tokens, md.options, {})), sections };8596 }8697 8798 /**

view file

tests/console.test.ts+1 −1
@@ -375,7 +375,7 @@375375 		const own = { ...settings, support_page: "## Help\n\nMail **me**." };376376 		expect((await operator.post("/admin/site", own)).status).toBe(303);377377 		const support = await text("/support");378-		expect(support).toContain("<h2>Help</h2>\n<p>Mail <strong>me</strong>.</p>");378+		expect(support).toContain("<h2 id=\"help\">Help</h2>\n<p>Mail <strong>me</strong>.</p>");379379 		expect(support).not.toBe(shipped);380380 		const { support: _, ...off } = own;381381 		expect((await operator.post("/admin/site", off)).status).toBe(303);

view file