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
+17
-156
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user