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:
John O'Keefe committed 2026-10-24 14:31:36 -04:00
1 parent 9d0ae2bcc4
commit 84cd6328f4
19 files changed
+2030 -1097

No files matched your search

+241 -68
View File
@@ -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>
+146
View File
@@ -0,0 +1,146 @@
package templates
import (
"context"
"strings"
"testing"
)
// The docs layout is a standalone documentation site: it must NOT embed the
// app chrome (its sidebar assumed a signed-in user) and must never contain a
// login form — the old layout posted credentials via htmx that was never
// loaded on this page, degrading to a native GET that put the username and
// password into the URL. These render tests pin that fix.
func TestDocsLayoutRendersStandalone(t *testing.T) {
nav := Navigation{
Sections: []NavSection{
{
Title: "Getting Started",
Icon: "book-open",
Items: []NavItem{{Title: "Overview", URL: "/docs"}},
},
{
Title: "User Guide",
Icon: "book",
Collapsed: true,
Items: []NavItem{{Title: "Dashboard", URL: "/docs/user/dashboard"}},
Groups: []NavGroup{
{
Title: "Devices",
Items: []NavItem{{Title: "Kobo Setup", URL: "/docs/user/devices/kobo-setup"}},
Collapsed: true,
},
},
},
},
}
doc := Document{
Title: "Test Doc",
Content: "<p>Hello docs</p>",
TOC: []TOCItem{{Level: 2, Title: "Section", Anchor: "section"}},
Breadcrumb: []BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
{Title: "User Guide"},
},
SourceFile: "user/test.md",
}
var b strings.Builder
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout: %v", err)
}
out := b.String()
for _, banned := range []string{
"app-sidebar", // app chrome
`hx-post="/api/auth/login"`, // no inline login form (the URL-leak bug)
`name="password"`, // credentials never render on docs pages
"prose-invert", // theme-aware prose, not hardcoded dark
"highlight-dark.min.css", // highlighting is server-side now
"cdn.tailwindcss.com", // no CDN dependencies
"lunr", // dead client-side search is gone
} {
if strings.Contains(out, banned) {
t.Errorf("docs layout must not contain %q", banned)
}
}
for _, want := range []string{
`href="/docs"`, // sidebar + logo links
`data-collapsed="true"`, // collapsed section state
`data-collapsed="false"`, // expanded section state
"Open App", // topbar action
"theme-select", // theme picker
"docs-search-results", // search dropdown target
"Hello docs", // content rendered
"page-docs",
} {
if !strings.Contains(out, want) {
t.Errorf("docs layout missing %q", want)
}
}
// Anonymous visitor: a sign-in LINK, never a form.
b.Reset()
if err := DocsLayout(nav, doc, User{Theme: "tokyo-night"}, "/docs/user/test").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsLayout anonymous: %v", err)
}
if !strings.Contains(b.String(), `href="/login"`) {
t.Error("anonymous docs layout should link to /login")
}
}
func TestDocsTopbarSignedIn(t *testing.T) {
var b strings.Builder
if err := DocsTopbar(User{ID: "u1", Username: "reader", Theme: "tokyo-night"}).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsTopbar: %v", err)
}
out := b.String()
if strings.Contains(out, `href="/login"`) {
t.Error("signed-in topbar must not offer /login")
}
if !strings.Contains(out, "reader") {
t.Error("signed-in topbar should surface the username")
}
}
func TestDocsSearchResultsPartial(t *testing.T) {
var b strings.Builder
results := []SearchResultItem{
{Title: "Kobo Setup", Category: "User Guide", URL: "/docs/user/devices/kobo-setup"},
}
if err := DocsSearchResults(results).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults: %v", err)
}
out := b.String()
for _, want := range []string{"Kobo Setup", "User Guide", `/docs/user/devices/kobo-setup`} {
if !strings.Contains(out, want) {
t.Errorf("search results missing %q", want)
}
}
// Empty state renders, not blank markup.
b.Reset()
if err := DocsSearchResults(nil).Render(context.Background(), &b); err != nil {
t.Fatalf("DocsSearchResults empty: %v", err)
}
if !strings.Contains(b.String(), "No matching pages.") {
t.Error("search results empty state missing")
}
}
func TestDocsNotFoundRenders(t *testing.T) {
var b strings.Builder
if err := DocsNotFound("Document Not Found", "no such page").Render(context.Background(), &b); err != nil {
t.Fatalf("DocsNotFound: %v", err)
}
out := b.String()
for _, want := range []string{"Document Not Found", "no such page", `href="/docs"`} {
if !strings.Contains(out, want) {
t.Errorf("404 page missing %q", want)
}
}
if strings.Contains(out, "cdn.tailwindcss.com") {
t.Error("404 page must not pull the tailwind CDN")
}
}
+793 -216
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -241,7 +241,7 @@ templ Header(user User, currentPath string) {
</header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=6"></script>
<script type="module" src="/static/main.js?v=7"></script>
</div>
}
+1 -1
View File
@@ -751,7 +751,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=6\"></script></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+20 -1
View File
@@ -209,8 +209,20 @@ type Navigation struct {
Sections []NavSection
}
// NavSection is a top-level documentation category. Items are documents that
// live directly in the section; Groups are subcategories (subdirectories).
type NavSection struct {
Title string
Icon string
Items []NavItem
Groups []NavGroup
Collapsed bool
}
// NavGroup is a subcategory inside a NavSection (a subdirectory of docs).
type NavGroup struct {
Title string
Icon string
Items []NavItem
Collapsed bool
}
@@ -218,7 +230,14 @@ type NavSection struct {
type NavItem struct {
Title string
URL string
Icon string
}
// SearchResultItem is one entry in the docs search results partial. The
// server renders it (templ); the client only swaps the markup in.
type SearchResultItem struct {
Title string
Category string
URL string
}
type Document struct {
+30 -1
View File
@@ -6,9 +6,9 @@ import (
"bookhoard/internal/utils"
"encoding/json"
"fmt"
"strconv"
"net/url"
"sort"
"strconv"
"strings"
"time"
@@ -33,6 +33,35 @@ func navItemClass(current, target string) string {
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
}
// docsNavClass returns classes for docs sidebar links (server-rendered active
// state — there is no client-side highlighter). indent sets the left padding,
// deeper for subcategory group items.
func docsNavClass(current, target, indent string) string {
base := "nav-item flex items-center " + indent + " pr-4 mr-2 py-1.5 rounded-lg text-sm transition-colors "
if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") {
return base + "bg-brand/15 text-brand font-medium"
}
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
}
// collapsibleState renders the data-collapsed attribute value for a docs nav
// items container; web/src/docs.ts toggles it on click.
func collapsibleState(collapsed bool) string {
if collapsed {
return "true"
}
return "false"
}
// chevronStyle pre-rotates a docs nav section/group chevron when collapsed
// (quote-free value: templ double-escapes quotes in style attributes).
func chevronStyle(collapsed bool) string {
if collapsed {
return "transform: rotate(-90deg);"
}
return "transform: rotate(0deg);"
}
// themeCheckClass returns the class suffix for a theme option's checkmark
// wrapper: empty for the active theme (visible), " hidden" otherwise. The
// check element is always rendered so updateThemeIndicators() can move it