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
↓ kepr-7120956a.tar.gzatlas at this save
files changed5 files
package.json+1 −1public/css/kepr.css+35 −0src/pages/docs.ts+12 −3src/pages/markdown.ts+14 −3tests/console.test.ts+1 −1package.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": {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) {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 }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 /**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);