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

+17 -156
View File
@@ -31,7 +31,11 @@ func NewDocsHandler(docsPath string) *DocsHandler {
extension.GFM,
extension.Table,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
// Chroma renders code blocks with inline styles, so the
// palette ships with the HTML — no client-side highlighter
// needed. The docs are read on dark themes most of the time;
// code blocks keep a dark surface under light themes too.
highlighting.WithStyle("github-dark"),
),
),
goldmark.WithParserOptions(
@@ -83,10 +87,6 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
// Debug: Log the raw HTML output from goldmark
htmlOutput := buf.String()
fmt.Printf("DEBUG: Goldmark output length: %d, first 200 chars: %q\n", len(htmlOutput), htmlOutput[:min(200, len(htmlOutput))])
// Extract title from first heading
title := h.extractTitle(content)
@@ -155,46 +155,35 @@ func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
return toc
}
// generateBreadcrumb generates breadcrumb navigation
// generateBreadcrumb generates breadcrumb navigation. Only "Docs" links out:
// directory segments have no landing pages, so they render as plain text
// (URL left empty). The current page is omitted — the h1 below shows it.
func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbItem {
parts := strings.Split(strings.TrimSuffix(docPath, ".md"), "/")
breadcrumb := []templates.BreadcrumbItem{
{Title: "Docs", URL: "/docs"},
}
path := ""
for i, part := range parts {
if part == "" {
continue
}
path += "/" + part
// Don't add the last part (current page)
if i < len(parts)-1 {
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
URL: "/docs" + path,
})
if i == len(parts)-1 {
break // current page
}
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
})
}
return breadcrumb
}
// getCategory determines the category of a document
// getCategory determines the category of a document. It reuses the nav
// routing (placeDoc) so search results and breadcrumbs name sections exactly
// as the sidebar does.
func (h *DocsHandler) getCategory(docPath string) string {
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
return "API Reference"
}
if strings.Contains(docPath, "devices") {
return "Device Setup"
}
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
return "Contributing"
}
if strings.Contains(docPath, "SYNC") {
return "Sync Guide"
}
return "Documentation"
return placeDoc(docPath).section
}
// slugify converts a string to a URL-safe slug
@@ -234,134 +223,6 @@ func (h *DocsHandler) ListDocuments() ([]string, error) {
return docs, err
}
// GetAPIEndpointData returns endpoint data for the API explorer
func (h *DocsHandler) GetAPIEndpointData(endpointPath string) (*templates.EndpointInfo, error) {
// Map endpoint path to data
// For now, return static examples based on the endpoint path
// In a full implementation, this would parse the markdown files
// Clean the path
endpointPath = strings.TrimPrefix(endpointPath, "api/")
endpointPath = strings.TrimSuffix(endpointPath, ".md")
// Map paths to endpoints
endpoints := map[string]templates.EndpointInfo{
"authentication/register": {
Method: "POST",
Path: "/api/auth/register",
RequestBody: `{
"email": "user@example.com",
"username": "john",
"password": "SecureP@ss123!",
"first_name": "John",
"last_name": "Doe"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user",
"theme": "tokyo-night",
"created_at": "2026-01-31T10:00:00Z"
}
}`,
Description: "Create a new user account",
},
"authentication/login": {
Method: "POST",
Path: "/api/auth/login",
RequestBody: `{
"email": "user@example.com",
"password": "SecureP@ss123!"
}`,
Response: `{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...",
"user": {
"id": "uuid-here",
"email": "user@example.com",
"username": "john",
"role": "user"
}
}`,
Description: "Authenticate with email and password",
},
"authentication/refresh_token": {
Method: "POST",
Path: "/api/auth/refresh",
RequestBody: `{
"refresh_token": "d4f5g6h7..."
}`,
Response: `{
"token": "new-jwt-token",
"refresh_token": "new-refresh-token"
}`,
Description: "Obtain a new JWT token using a refresh token",
},
"authentication/logout": {
Method: "POST",
Path: "/api/auth/logout",
RequestBody: `{}`,
Response: `{}`,
Description: "Invalidate the current JWT token",
},
"users/get_profile": {
Method: "GET",
Path: "/api/users/me",
RequestBody: `{}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Doe",
"theme": "tokyo-night",
"role": "user",
"max_devices": 10,
"created_at": "2026-01-31T10:00:00Z"
}`,
Description: "Retrieve the current authenticated user's profile",
},
"users/update_profile": {
Method: "PUT",
Path: "/api/users/me/profile",
RequestBody: `{
"first_name": "John",
"last_name": "Smith"
}`,
Response: `{
"id": "uuid",
"email": "user@example.com",
"username": "john",
"first_name": "John",
"last_name": "Smith",
"theme": "tokyo-night",
"role": "user"
}`,
Description: "Update the current user's profile information",
},
"users/change_password": {
Method: "PUT",
Path: "/api/users/me/password",
RequestBody: `{
"current_password": "oldPassword",
"new_password": "NewSecureP@ss123!"
}`,
Response: `{}`,
Description: "Change the current user's password",
},
}
if endpoint, ok := endpoints[endpointPath]; ok {
return &endpoint, nil
}
return nil, fmt.Errorf("endpoint not found: %s", endpointPath)
}
// SearchDoc represents a document for search indexing
type SearchDoc struct {
ID string `json:"id"`