feat(docs): standalone docs site chrome + category/subcategory navigation
Docs pages no longer embed the app Header (whose hx-post login form silently degraded to a native GET, leaking credentials into the URL — this page never loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous visitors get a plain /login link. Optional-auth middleware (hand-rolled — echojwt's ErrorHandler return ends the chain, producing empty 200s) populates the user for signed-in visitors. Navigation rebuilt from the docs directory tree: 6 sections, subcategory groups, server-rendered expansion and active states. Search results are a templ partial (/docs/search/partial); code highlighting is server-side chroma (previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars (prose-invert painted unreadable text under light themes). /docs renders the index directly; standalone 404 without the tailwind CDN. Tests: navigation routing/expansion (internal/docs), render pins asserting no login form/password field/app sidebar ever return on docs pages. lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts baseline 5 -> 1.
This commit is contained in:
1 parent
9d0ae2bcc4
commit
84cd6328f4
19 files changed
+2030
-1097
No files matched your search
+241
-68
@@ -2,88 +2,50 @@ package templates
|
||||
|
||||
import "fmt"
|
||||
|
||||
// DocsLayout renders the standalone documentation site: a docs-owned topbar
|
||||
// (search, theme picker, Open App), a docs-only sidebar with category →
|
||||
// subcategory navigation, and the markdown content.
|
||||
//
|
||||
// It deliberately does NOT embed the app Header: docs are a public surface
|
||||
// (setup and troubleshooting happen pre-login), and the app chrome assumed a
|
||||
// signed-in user — it rendered a sign-in form that silently degraded into a
|
||||
// native GET submit (credentials into the URL) because this page never loaded
|
||||
// htmx. Signed-out visitors get a plain "Sign in" LINK here; the form lives
|
||||
// on /login where it belongs.
|
||||
templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ThemeFlashGuard()
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
|
||||
</head>
|
||||
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
@Header(user, currentPath)
|
||||
<button
|
||||
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
|
||||
onclick="toggleSidebar()"
|
||||
aria-label="Toggle documentation menu"
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div>
|
||||
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex gap-8 py-8">
|
||||
<aside id="docs-sidebar" class="sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0">
|
||||
<div class="p-4 border-b border-line">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input input pl-9"
|
||||
placeholder="Search documentation…"
|
||||
id="docs-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
for _, section := range nav.Sections {
|
||||
<div class="nav-section border-b border-line">
|
||||
<div
|
||||
class="nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
@click="toggleSection($el)"
|
||||
>
|
||||
<span>{ section.Title }</span>
|
||||
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
|
||||
</div>
|
||||
if section.Collapsed {
|
||||
<div class="nav-items hidden" data-collapsed="true">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||
<span>{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="nav-items" data-collapsed="false">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||
<span>{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
<main class="main-content flex-1 min-w-0 max-w-4xl">
|
||||
<body x-data="docs" x-init="initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
@DocsTopbar(user)
|
||||
<!-- Mobile drawer backdrop -->
|
||||
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div>
|
||||
<div class="flex">
|
||||
@DocsSidebar(nav, currentPath)
|
||||
<main class="flex-1 min-w-0">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-10">
|
||||
if len(doc.Breadcrumb) > 0 {
|
||||
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
||||
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-6" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
if i > 0 {
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
}
|
||||
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
||||
if crumb.URL != "" {
|
||||
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
||||
} else {
|
||||
<span>{ crumb.Title }</span>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
||||
if len(doc.TOC) > 0 {
|
||||
<details class="toc card p-4 mb-8">
|
||||
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
|
||||
@@ -95,7 +57,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<a
|
||||
href={ "#" + item.Anchor }
|
||||
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
|
||||
style={ "margin-left: " + fmt.Sprintf("%d", item.Level-2) + "rem; color: var(--text-secondary);" }
|
||||
>
|
||||
{ item.Title }
|
||||
</a>
|
||||
@@ -103,10 +65,221 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<div class="prose max-w-none">
|
||||
@UnsafeHTML(doc.Content).ToComponent()
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
|
||||
<script type="module" src="/static/main.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// DocsTopbar is the docs site's own slim header. It never contains a login
|
||||
// form — anonymous visitors get a link to /login (the only place credentials
|
||||
// are posted from).
|
||||
templ DocsTopbar(user User) {
|
||||
<header class="sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6" style="background: var(--bg-primary); border-color: var(--border);">
|
||||
<button
|
||||
type="button"
|
||||
class="lg:hidden icon-btn shrink-0"
|
||||
@click="toggleSidebar()"
|
||||
aria-label="Toggle documentation menu"
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<a href="/docs" class="flex items-center gap-2 shrink-0 no-underline">
|
||||
<span class="grid place-items-center" style="color: var(--accent);">
|
||||
@Icon("book-open", "h-6 w-6")
|
||||
</span>
|
||||
<span class="hidden sm:inline font-bold text-lg tracking-tight" style="color: var(--text-primary);">Bookhoard</span>
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" style="background: var(--accent-muted); color: var(--accent);">Docs</span>
|
||||
</a>
|
||||
<div class="flex-1"></div>
|
||||
@DocsSearchBox()
|
||||
<div class="hidden md:block h-6 w-px shrink-0" style="background: var(--border);"></div>
|
||||
@DocsThemePicker()
|
||||
<div class="hidden md:flex items-center gap-2 shrink-0">
|
||||
if user.ID != "" {
|
||||
<a
|
||||
href="/profile"
|
||||
class="icon-btn"
|
||||
title={ "Signed in as " + user.Username }
|
||||
aria-label="Your profile"
|
||||
>
|
||||
@Icon("user", "h-5 w-5")
|
||||
</a>
|
||||
} else {
|
||||
<a href="/login" class="text-sm font-medium hover:underline" style="color: var(--text-secondary);">Sign in</a>
|
||||
}
|
||||
<a href="/" class="btn btn-primary py-1.5 px-3 text-sm no-underline">Open App</a>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
// DocsSearchBox hosts the header search input. Results are a server-rendered
|
||||
// partial (GET /docs/search/partial) swapped into #docs-search-results by
|
||||
// web/src/docs.ts — the server renders what results look like.
|
||||
templ DocsSearchBox() {
|
||||
<div
|
||||
class="relative flex-1 max-w-72"
|
||||
x-data="{ open: false }"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape="open = false"
|
||||
>
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input input pl-9 py-1.5 text-sm"
|
||||
placeholder="Search docs…"
|
||||
id="docs-search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="docs-search-results"
|
||||
class="hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40"
|
||||
style="background: var(--bg-secondary); border-color: var(--border);"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// DocsThemePicker reuses the app's theme Alpine component: initializeTheme
|
||||
// applies the saved (and, when signed in, server) theme and syncs the select;
|
||||
// changeTheme persists the choice. Anchors in the content smooth-scroll via
|
||||
// the same init.
|
||||
templ DocsThemePicker() {
|
||||
<div x-data="theme" x-init="initializeTheme()" class="shrink-0">
|
||||
<select
|
||||
id="theme-select"
|
||||
@change="changeTheme($event.target.value)"
|
||||
class="input py-1.5 pr-8 text-sm cursor-pointer w-auto"
|
||||
aria-label="Theme"
|
||||
>
|
||||
<optgroup label="Dark">
|
||||
for _, opt := range ThemeOptionsFor(ModeDark) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Light">
|
||||
for _, opt := range ThemeOptionsFor(ModeLight) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
// DocsSidebar renders the category → subcategory navigation. Sections and
|
||||
// groups toggle collapsed/expanded client-side (pure open/closed state);
|
||||
// the server decides what is expanded on first paint via data-collapsed.
|
||||
templ DocsSidebar(nav Navigation, currentPath string) {
|
||||
<aside
|
||||
id="docs-sidebar"
|
||||
class="sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line"
|
||||
>
|
||||
<nav class="py-2" aria-label="Documentation">
|
||||
for _, section := range nav.Sections {
|
||||
<div class="nav-section border-b border-line last:border-b-0 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
@click="toggleCollapsible($el)"
|
||||
>
|
||||
<span class="shrink-0" style="color: var(--accent);">
|
||||
@Icon(section.Icon, "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-left">{ section.Title }</span>
|
||||
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(section.Collapsed) }>
|
||||
@Icon("chevron-down", "h-4 w-4")
|
||||
</span>
|
||||
</button>
|
||||
<div class="nav-items pb-1" data-collapsed={ collapsibleState(section.Collapsed) }>
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-7") }>
|
||||
<span>{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
for _, group := range section.Groups {
|
||||
<div class="mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
@click="toggleCollapsible($el)"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">{ group.Title }</span>
|
||||
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(group.Collapsed) }>
|
||||
@Icon("chevron-down", "h-3.5 w-3.5")
|
||||
</span>
|
||||
</button>
|
||||
<div class="nav-group-items" data-collapsed={ collapsibleState(group.Collapsed) }>
|
||||
for _, item := range group.Items {
|
||||
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-9") }>
|
||||
<span class="truncate">{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
}
|
||||
|
||||
// DocsSearchResults renders the search results partial swapped into the
|
||||
// header dropdown. Server-rendered on purpose (rendering ownership): the
|
||||
// client never builds result markup from JSON.
|
||||
templ DocsSearchResults(results []SearchResultItem) {
|
||||
if len(results) == 0 {
|
||||
<div class="px-4 py-3 text-sm" style="color: var(--text-secondary);">No matching pages.</div>
|
||||
} else {
|
||||
<ul class="py-1 list-none m-0 p-0">
|
||||
for _, r := range results {
|
||||
<li>
|
||||
<a href={ r.URL } class="block px-4 py-2 no-underline transition-colors hover:bg-surface-hover" onclick="this.closest('#docs-search-results').classList.add('hidden')">
|
||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary);">{ r.Title }</span>
|
||||
if r.Category != "" {
|
||||
<span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
// DocsNotFound renders the standalone docs 404 page: same theme tokens, no
|
||||
// app chrome, no CDN dependencies.
|
||||
templ DocsNotFound(title string, message string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ThemeFlashGuard()
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title } - Bookhoard Documentation</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen font-sans antialiased">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="card p-8 max-w-md w-full text-center">
|
||||
<span class="inline-grid place-items-center mb-4" style="color: var(--accent);">
|
||||
@Icon("book-open", "h-10 w-10")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--text-primary);">{ title }</h1>
|
||||
<p class="text-sm mb-6" style="color: var(--text-secondary);">{ message }</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<a href="/docs" class="btn btn-primary py-2 px-4 text-sm no-underline">Documentation</a>
|
||||
<a href="/" class="btn py-2 px-4 text-sm no-underline">Bookhoard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user