script-src now allow-lists the guard by sha256 of its rendered bytes (middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the guard is the app's only inline <script>. security_headers_test.go renders templates.ThemeFlashGuard() and fails with the replacement hash if the script text ever drifts. Executing anti-XSS strict CSP surfaced three latent violations the eval-removal sweep couldn't see (hash mode is stricter than eval mode, because inline event handler ATTRIBUTES now count against script-src): 1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers (modal open/close, folder-browser picker, purge button) converted to Alpine @click/@submit component methods — openCreateLibraryModal, hideModal, pickBrowsePath added to the admin component; purgeArchivedItems promoted from its window global into the component and the (window as any) exposure removed. 2. dashboard/docs: onclick handlers converted — dashboard's event.stopPropagation() → @click.stop; docs' sidebar/search closers → closeDocsSidebar/closeSearchResults component methods. 3. 8 onerror= image-fallback attributes (cover placeholders app-wide + audiobook cover hide) replaced by data-fallback attributes handled by one delegated capture-phase listener in web/src/image_fallback.ts — works for htmx-swapped content without re-binding. Verified: theme guard applies a saved theme on public pages under the hash CSP; 14-page authenticated sweep clean; admin pages clean under the promoted admin; admin-library modal open/close bindings exercised; readers render error-free; go build/vet/middleware-test, tsc, vp check/test (146), build:ts all green.
288 lines
11 KiB
Templ
288 lines
11 KiB
Templ
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">
|
|
<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"/>
|
|
</head>
|
|
<body x-data="docs" 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);" @click="closeDocsSidebar($event)"></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-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")
|
|
}
|
|
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-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);">
|
|
<span>On this page</span>
|
|
@Icon("chevron-down", "h-4 w-4 ml-auto")
|
|
</summary>
|
|
<div class="mt-3">
|
|
for _, item := range doc.TOC {
|
|
<a
|
|
href={ "#" + item.Anchor }
|
|
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
|
style={ "margin-left: " + fmt.Sprintf("%d", item.Level-2) + "rem; color: var(--text-secondary);" }
|
|
>
|
|
{ item.Title }
|
|
</a>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
<div class="prose max-w-none">
|
|
@UnsafeHTML(doc.Content).ToComponent()
|
|
</div>
|
|
</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" @click="closeSearchResults($event)">
|
|
<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="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>
|
|
</html>
|
|
}
|