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"`
|
||||
|
||||
+118
-329
@@ -25,6 +25,49 @@ func NewHTTPHandler(docsPath string) *HTTPHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// currentUser builds the template user from the optional auth middleware.
|
||||
// Docs routes render for both anonymous and signed-in visitors, so an empty
|
||||
// context user is a normal, expected state here.
|
||||
func (h *HTTPHandler) currentUser(c *echo.Context) templates.User {
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
theme := "tokyo-night"
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
}
|
||||
return templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
}
|
||||
return templates.User{Theme: "tokyo-night"}
|
||||
}
|
||||
|
||||
// renderDocPage renders a document inside the docs layout.
|
||||
func (h *HTTPHandler) renderDocPage(c *echo.Context, doc *templates.Document) error {
|
||||
currentPath := c.Request().URL.Path
|
||||
nav := h.docs.BuildNavigation(currentPath)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.DocsLayout(*nav, *doc, h.currentUser(c), currentPath).Render(c.Request().Context(), &buf); err != nil {
|
||||
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// notFoundPage renders the standalone 404 page (no app chrome, no CDN).
|
||||
func (h *HTTPHandler) notFoundPage(c *echo.Context, title, message string) error {
|
||||
var buf bytes.Buffer
|
||||
if err := templates.DocsNotFound(title, message).Render(c.Request().Context(), &buf); err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
}
|
||||
return c.HTML(http.StatusNotFound, buf.String())
|
||||
}
|
||||
|
||||
// ShowDocumentation renders a documentation page
|
||||
func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
|
||||
// Get the document path from URL parameter
|
||||
@@ -38,312 +81,96 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
|
||||
return h.ShowAPIEndpoint(c, strings.TrimPrefix(docPath, "api/"))
|
||||
}
|
||||
|
||||
// Return error page
|
||||
errorHTML := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Document Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Document Not Found</h1>
|
||||
<p class="mb-4">%s</p>
|
||||
<a href="/docs" class="text-blue-400 hover:underline">Return to Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, err.Error())
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
return h.notFoundPage(c, "Document Not Found", err.Error())
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Render documentation page
|
||||
var buf bytes.Buffer
|
||||
currentPath := c.Request().URL.Path
|
||||
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
// DocsHome renders the documentation index at /docs.
|
||||
func (h *HTTPHandler) DocsHome(c *echo.Context) error {
|
||||
doc, err := h.docs.LoadDocument("index.md")
|
||||
if err != nil {
|
||||
fmt.Printf("DOCS RENDER ERROR: %v\n", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page: "+err.Error())
|
||||
return h.notFoundPage(c, "Documentation Not Found", err.Error())
|
||||
}
|
||||
fmt.Printf("DOCS: Successfully rendered %s (%d bytes)\n", docPath, buf.Len())
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// ShowAPIEndpoint shows a specific API endpoint documentation
|
||||
func (h *HTTPHandler) ShowAPIEndpoint(c *echo.Context, endpointPath string) error {
|
||||
// Check if user is logged in
|
||||
isLoggedIn := false
|
||||
if userID := c.Get("user_id"); userID != nil {
|
||||
isLoggedIn = true
|
||||
}
|
||||
|
||||
// Get endpoint data for the explorer
|
||||
endpointInfo, err := h.docs.GetAPIEndpointData(endpointPath)
|
||||
_ = endpointInfo
|
||||
if err != nil {
|
||||
// Endpoint not found in explorer data, fall back to old behavior
|
||||
return h.showLegacyAPIEndpoint(c, endpointPath, isLoggedIn)
|
||||
}
|
||||
|
||||
// Load the markdown documentation for this endpoint
|
||||
docPath := "api/" + endpointPath + ".md"
|
||||
doc, err := h.docs.LoadDocument(docPath)
|
||||
if err != nil {
|
||||
errorHTML := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API Endpoint Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
|
||||
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
return h.notFoundPage(c, "API Endpoint Not Found", "No documentation exists for this endpoint.")
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
return h.renderDocPage(c, doc)
|
||||
}
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
// searchDocs runs the docs search and returns ranked, capped results.
|
||||
// Title matches outrank content matches.
|
||||
func (h *HTTPHandler) searchDocs(query string) []templates.SearchResultItem {
|
||||
queryLower := strings.ToLower(strings.TrimSpace(query))
|
||||
if queryLower == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render API endpoint page
|
||||
var buf bytes.Buffer
|
||||
currentPath := c.Request().URL.Path
|
||||
err = templates.DocsLayout(*nav, *doc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
docs, err := h.docs.ListDocuments()
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
return nil
|
||||
}
|
||||
|
||||
var titleMatches, contentMatches []templates.SearchResultItem
|
||||
for _, docPath := range docs {
|
||||
doc, err := h.docs.LoadDocument(docPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
item := templates.SearchResultItem{
|
||||
Title: doc.Title,
|
||||
Category: doc.Category,
|
||||
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
|
||||
}
|
||||
if doc.SourceFile == "index.md" {
|
||||
item.URL = "/docs"
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(doc.Title), queryLower) {
|
||||
titleMatches = append(titleMatches, item)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(doc.Content), queryLower) {
|
||||
contentMatches = append(contentMatches, item)
|
||||
}
|
||||
}
|
||||
|
||||
results := append(titleMatches, contentMatches...)
|
||||
const maxResults = 30
|
||||
if len(results) > maxResults {
|
||||
results = results[:maxResults]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// SearchPartial renders the search results HTML partial for the docs header
|
||||
// search box. The client swaps this markup in verbatim (rendering ownership:
|
||||
// the server renders the results, TypeScript only moves them).
|
||||
func (h *HTTPHandler) SearchPartial(c *echo.Context) error {
|
||||
results := h.searchDocs(c.QueryParam("q"))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.DocsSearchResults(results).Render(c.Request().Context(), &buf); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to render search results",
|
||||
})
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// showLegacyAPIEndpoint shows API endpoint documentation without explorer data
|
||||
func (h *HTTPHandler) showLegacyAPIEndpoint(c *echo.Context, endpointPath string, isLoggedIn bool) error {
|
||||
endpoints := h.docs.GetAPIEndpoints()
|
||||
|
||||
// Find the endpoint
|
||||
var foundEndpoint *APIEndpoint
|
||||
for _, ep := range endpoints {
|
||||
if strings.HasSuffix(endpointPath, strings.TrimPrefix(ep.Path, "/api/")) {
|
||||
foundEndpoint = &ep
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundEndpoint == nil {
|
||||
errorHTML := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API Endpoint Not Found - Bookhoard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">API Endpoint Not Found</h1>
|
||||
<a href="/docs/api" class="text-blue-400 hover:underline">Return to API Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return c.HTML(http.StatusNotFound, errorHTML)
|
||||
}
|
||||
|
||||
// Get navigation
|
||||
nav := h.docs.BuildNavigation()
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
currentPath := c.Request().URL.Path
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Render API endpoint page
|
||||
var buf bytes.Buffer
|
||||
err := templates.DocsLayout(*nav, templates.Document{
|
||||
Title: foundEndpoint.Title,
|
||||
Content: fmt.Sprintf("<p>Documentation for <code>%s %s</code></p>", foundEndpoint.Method, foundEndpoint.Path),
|
||||
TOC: []templates.TOCItem{},
|
||||
Breadcrumb: []templates.BreadcrumbItem{
|
||||
{Title: "Docs", URL: "/docs"},
|
||||
{Title: "API Reference", URL: "/docs/api"},
|
||||
{Title: foundEndpoint.Category, URL: "/docs/api"},
|
||||
{Title: foundEndpoint.Title, URL: ""},
|
||||
},
|
||||
Category: "API Reference",
|
||||
SourceFile: "api",
|
||||
}, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
}
|
||||
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// DocsHome redirects to the documentation index
|
||||
func (h *HTTPHandler) DocsHome(c *echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/docs/index.md")
|
||||
}
|
||||
|
||||
// APIHome shows the API documentation home page
|
||||
func (h *HTTPHandler) APIHome(c *echo.Context) error {
|
||||
endpoints := h.docs.GetAPIEndpoints()
|
||||
nav := h.docs.BuildNavigation()
|
||||
|
||||
// Check if user is authenticated via middleware
|
||||
var user templates.User
|
||||
currentPath := c.Request().URL.Path
|
||||
if userID := c.Get("user"); userID != nil {
|
||||
dbUser := userID.(database.Users)
|
||||
var theme string
|
||||
if dbUser.Theme.Valid {
|
||||
theme = dbUser.Theme.String
|
||||
} else {
|
||||
theme = "tokyo-night"
|
||||
}
|
||||
user = templates.User{
|
||||
ID: uuid.UUID(dbUser.ID.Bytes).String(),
|
||||
Username: dbUser.Username,
|
||||
Email: dbUser.Email,
|
||||
Role: dbUser.Role,
|
||||
Theme: theme,
|
||||
}
|
||||
} else {
|
||||
// Create empty user for unauthenticated users
|
||||
user = templates.User{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Email: "",
|
||||
Role: "",
|
||||
Theme: "tokyo-night",
|
||||
}
|
||||
}
|
||||
|
||||
// Build API documentation content
|
||||
content := "<h2>API Endpoints</h2><p>Complete API reference for Bookhoard v1.0.</p><ul>"
|
||||
for _, ep := range endpoints {
|
||||
content += fmt.Sprintf(`<li><a href="/docs/api%s"><strong>%s %s</strong></a> - %s</li>`,
|
||||
strings.TrimPrefix(ep.Path, "/api"),
|
||||
ep.Method,
|
||||
ep.Path,
|
||||
ep.Title,
|
||||
)
|
||||
}
|
||||
content += "</ul>"
|
||||
|
||||
apiDoc := &templates.Document{
|
||||
Title: "API Reference",
|
||||
Content: content,
|
||||
TOC: []templates.TOCItem{},
|
||||
Breadcrumb: []templates.BreadcrumbItem{
|
||||
{Title: "Docs", URL: "/docs"},
|
||||
{Title: "Developer", URL: "/docs/developer"},
|
||||
{Title: "API Reference", URL: ""},
|
||||
},
|
||||
Category: "API Reference",
|
||||
SourceFile: "developer/api-reference.md",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := templates.DocsLayout(*nav, *apiDoc, user, currentPath).Render(c.Request().Context(), &buf)
|
||||
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render page")
|
||||
}
|
||||
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// Search searches through documentation
|
||||
// Search searches through documentation (JSON API).
|
||||
func (h *HTTPHandler) Search(c *echo.Context) error {
|
||||
query := c.QueryParam("q")
|
||||
if query == "" {
|
||||
@@ -352,48 +179,19 @@ func (h *HTTPHandler) Search(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Get all documents
|
||||
docs, err := h.docs.ListDocuments()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to list documents",
|
||||
results := h.searchDocs(query)
|
||||
jsonResults := make([]SearchResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
jsonResults = append(jsonResults, SearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Type: r.Category,
|
||||
})
|
||||
}
|
||||
|
||||
// Simple search implementation
|
||||
var results []SearchResult
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for _, docPath := range docs {
|
||||
doc, err := h.docs.LoadDocument(docPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if title matches
|
||||
if strings.Contains(strings.ToLower(doc.Title), queryLower) {
|
||||
results = append(results, SearchResult{
|
||||
Title: doc.Title,
|
||||
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
|
||||
Type: "Documentation",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content matches (simple substring search)
|
||||
contentLower := strings.ToLower(doc.Content)
|
||||
if strings.Contains(contentLower, queryLower) {
|
||||
results = append(results, SearchResult{
|
||||
Title: doc.Title,
|
||||
URL: "/docs/" + strings.TrimSuffix(doc.SourceFile, ".md"),
|
||||
Type: "Documentation",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"results": jsonResults,
|
||||
"count": len(jsonResults),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,15 +202,6 @@ type SearchResult struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
// TryEndpoint executes an API endpoint (for the interactive explorer)
|
||||
func (h *HTTPHandler) TryEndpoint(c *echo.Context) error {
|
||||
// This would execute the actual API request
|
||||
// For now, return a placeholder
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "API explorer not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// ServeSearchIndex serves the Lunr.js search index
|
||||
func (h *HTTPHandler) ServeSearchIndex(c *echo.Context) error {
|
||||
index, err := h.docs.GenerateSearchIndex()
|
||||
|
||||
+293
-208
@@ -10,8 +10,177 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// BuildNavigation creates the navigation structure from the docs filesystem
|
||||
func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
// sectionOrder defines the sidebar's top-level categories, top to bottom.
|
||||
// The docs directory layout IS the navigation structure: top-level
|
||||
// directories map to sections, nested directories map to subcategory
|
||||
// groups (see placeDoc for the routing rules).
|
||||
var sectionOrder = []struct {
|
||||
Title string
|
||||
Icon string
|
||||
}{
|
||||
{Title: "Getting Started", Icon: "book-open"},
|
||||
{Title: "User Guide", Icon: "book"},
|
||||
{Title: "API Reference", Icon: "list"},
|
||||
{Title: "Developer", Icon: "gear"},
|
||||
{Title: "Operations", Icon: "database"},
|
||||
{Title: "Contributing", Icon: "heart"},
|
||||
}
|
||||
|
||||
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
|
||||
var groupTitleOverrides = map[string]string{
|
||||
"admin": "Admin",
|
||||
"api": "API",
|
||||
"kobo": "Kobo",
|
||||
"koreader": "KOReader",
|
||||
"opds": "OPDS",
|
||||
"websocket": "WebSocket",
|
||||
}
|
||||
|
||||
// docTitleOverrides pins exact nav titles for files whose filename reads
|
||||
// poorly (or which serve as a landing page). Keyed by doc path; "index.md"
|
||||
// covers every subdirectory index page.
|
||||
var docTitleOverrides = map[string]string{
|
||||
"index.md": "Overview",
|
||||
"contract_pins.md": "Contract Pins",
|
||||
"FRONTEND_INTEGRATION.md": "Frontend Integration",
|
||||
"user/user-guide.md": "User Guide",
|
||||
"user/sync-guide.md": "Sync Guide",
|
||||
"user/devices/kobo-setup.md": "Kobo Setup",
|
||||
"user/devices/koreader-setup.md": "KOReader Setup",
|
||||
"user/devices/android-app.md": "Android App",
|
||||
"developer/development.md": "Developer Portal",
|
||||
"developer/api/api-reference.md": "Overview",
|
||||
"operations/operations.md": "Operations Guide",
|
||||
"operations/troubleshooting.md": "Troubleshooting",
|
||||
"contributing/contributing.md": "Contributing",
|
||||
"contributing/development.md": "Development Guide",
|
||||
"development/calibre-opf-implementation.md": "Calibre OPF Implementation",
|
||||
}
|
||||
|
||||
// navPlacement routes one document into (section, group). An empty group
|
||||
// means the doc sits directly in the section; a negative pin forces the doc
|
||||
// to the front of its list (landing pages like the root Overview).
|
||||
type navPlacement struct {
|
||||
section string
|
||||
group string
|
||||
pin int
|
||||
}
|
||||
|
||||
// placeDoc maps a doc path to its sidebar location.
|
||||
func placeDoc(docPath string) navPlacement {
|
||||
// Root landing page.
|
||||
if docPath == "index.md" {
|
||||
return navPlacement{section: "Getting Started", pin: -100}
|
||||
}
|
||||
// Stray root-level dev notes → Developer → Reference group.
|
||||
if !strings.Contains(docPath, "/") {
|
||||
return navPlacement{section: "Developer", group: "Reference"}
|
||||
}
|
||||
if strings.HasPrefix(docPath, "development/") {
|
||||
return navPlacement{section: "Developer", group: "Reference"}
|
||||
}
|
||||
|
||||
// Subdirectory index pages pin to the front of their group as "Overview".
|
||||
pin := 0
|
||||
if strings.HasSuffix(docPath, "/index.md") {
|
||||
pin = -50
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(docPath, "user/"):
|
||||
rest := strings.TrimPrefix(docPath, "user/")
|
||||
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
||||
// Subdirectories become subcategories: device setup gets its
|
||||
// own group, the admin-facing guides another.
|
||||
if dir == "devices" {
|
||||
return navPlacement{section: "User Guide", group: "Devices", pin: pin}
|
||||
}
|
||||
return navPlacement{section: "User Guide", group: titleCase(dir), pin: pin}
|
||||
}
|
||||
if rest == "user-guide.md" {
|
||||
return navPlacement{section: "User Guide", pin: -50}
|
||||
}
|
||||
if strings.HasPrefix(rest, "admin-") {
|
||||
return navPlacement{section: "User Guide", group: "Administration"}
|
||||
}
|
||||
return navPlacement{section: "User Guide"}
|
||||
|
||||
case strings.HasPrefix(docPath, "developer/api/"):
|
||||
rest := strings.TrimPrefix(docPath, "developer/api/")
|
||||
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
||||
return navPlacement{section: "API Reference", group: groupTitle(dir), pin: pin}
|
||||
}
|
||||
if rest == "api-reference.md" {
|
||||
return navPlacement{section: "API Reference", pin: -50}
|
||||
}
|
||||
return navPlacement{section: "API Reference"}
|
||||
|
||||
case strings.HasPrefix(docPath, "developer/"):
|
||||
rest := strings.TrimPrefix(docPath, "developer/")
|
||||
if dir, _, ok := strings.Cut(rest, "/"); ok {
|
||||
return navPlacement{section: "Developer", group: groupTitle(dir), pin: pin}
|
||||
}
|
||||
if rest == "development.md" {
|
||||
return navPlacement{section: "Developer", pin: -50}
|
||||
}
|
||||
return navPlacement{section: "Developer"}
|
||||
|
||||
case strings.HasPrefix(docPath, "operations/"):
|
||||
if docPath == "operations/operations.md" {
|
||||
return navPlacement{section: "Operations", pin: -50}
|
||||
}
|
||||
return navPlacement{section: "Operations"}
|
||||
|
||||
case strings.HasPrefix(docPath, "contributing/"):
|
||||
if docPath == "contributing/contributing.md" {
|
||||
return navPlacement{section: "Contributing", pin: -50}
|
||||
}
|
||||
return navPlacement{section: "Contributing"}
|
||||
|
||||
default:
|
||||
return navPlacement{section: "Developer", group: "Reference"}
|
||||
}
|
||||
}
|
||||
|
||||
// groupTitle turns a directory name into a subcategory title.
|
||||
func groupTitle(dir string) string {
|
||||
if title, ok := groupTitleOverrides[dir]; ok {
|
||||
return title
|
||||
}
|
||||
return titleCase(dir)
|
||||
}
|
||||
|
||||
// titleCase converts "book-matching"/"media_items" to "Book Matching".
|
||||
func titleCase(s string) string {
|
||||
s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
|
||||
return cases.Title(language.English).String(s)
|
||||
}
|
||||
|
||||
// docTitle returns the nav label for a document.
|
||||
func docTitle(docPath string) string {
|
||||
if title, ok := docTitleOverrides[docPath]; ok {
|
||||
return title
|
||||
}
|
||||
filename := docPath[strings.LastIndex(docPath, "/")+1:]
|
||||
if filename == "index.md" {
|
||||
return "Overview"
|
||||
}
|
||||
return titleCase(strings.TrimSuffix(filename, ".md"))
|
||||
}
|
||||
|
||||
// docURL converts a doc path to its site URL. The root index lives at /docs.
|
||||
func docURL(docPath string) string {
|
||||
if docPath == "index.md" {
|
||||
return "/docs"
|
||||
}
|
||||
return "/docs/" + strings.TrimSuffix(docPath, ".md")
|
||||
}
|
||||
|
||||
// BuildNavigation creates the category → subcategory navigation from the
|
||||
// docs filesystem. The section and group containing currentPath render
|
||||
// expanded; everything else starts collapsed (with Getting Started as the
|
||||
// fallback when nothing matches).
|
||||
func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation {
|
||||
docs, err := h.ListDocuments()
|
||||
if err != nil {
|
||||
// Return minimal navigation on error
|
||||
@@ -19,6 +188,7 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
Sections: []templates.NavSection{
|
||||
{
|
||||
Title: "Getting Started",
|
||||
Icon: "book-open",
|
||||
Items: []templates.NavItem{
|
||||
{Title: "Overview", URL: "/docs"},
|
||||
},
|
||||
@@ -27,223 +197,138 @@ func (h *DocsHandler) BuildNavigation() *templates.Navigation {
|
||||
}
|
||||
}
|
||||
|
||||
// Group documents by category
|
||||
categories := make(map[string][]templates.NavItem)
|
||||
type groupBuild struct {
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
overview bool // has a pinned index page
|
||||
}
|
||||
type sectionBuild struct {
|
||||
items []templates.NavItem
|
||||
urls []string
|
||||
pinned map[string]bool // landing pages lead their section
|
||||
groups map[string]*groupBuild
|
||||
overview bool
|
||||
}
|
||||
|
||||
builds := make(map[string]*sectionBuild, len(sectionOrder))
|
||||
for _, spec := range sectionOrder {
|
||||
builds[spec.Title] = §ionBuild{groups: make(map[string]*groupBuild), pinned: make(map[string]bool)}
|
||||
}
|
||||
|
||||
for _, doc := range docs {
|
||||
// Skip INDEX.md files in subdirectories (they're section landing pages, not nav items)
|
||||
// Only show root INDEX.md, skip all others like user/INDEX.md, api/INDEX.md, etc.
|
||||
if strings.HasSuffix(doc, "index.md") && doc != "index.md" {
|
||||
// ListDocuments returns paths without the .md extension; the
|
||||
// routing/title helpers key on full doc paths.
|
||||
docPath := doc + ".md"
|
||||
place := placeDoc(docPath)
|
||||
build, ok := builds[place.section]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
item := h.createNavItem(doc)
|
||||
category := h.getCategoryForNav(doc)
|
||||
categories[category] = append(categories[category], item)
|
||||
}
|
||||
|
||||
// Build navigation sections
|
||||
var sections []templates.NavSection
|
||||
|
||||
// Define section order
|
||||
sectionOrder := []string{
|
||||
"Getting Started",
|
||||
"User Guide",
|
||||
"Device Setup",
|
||||
"API Reference",
|
||||
"Contributing",
|
||||
}
|
||||
|
||||
// Add sections in order
|
||||
for _, sectionTitle := range sectionOrder {
|
||||
if items, exists := categories[sectionTitle]; exists {
|
||||
sections = append(sections, templates.NavSection{
|
||||
Title: sectionTitle,
|
||||
Items: items,
|
||||
Collapsed: sectionTitle != "Getting Started", // Expand first section
|
||||
})
|
||||
delete(categories, sectionTitle)
|
||||
item := templates.NavItem{Title: docTitle(docPath), URL: docURL(docPath)}
|
||||
if place.group == "" {
|
||||
build.items = append(build.items, item)
|
||||
build.urls = append(build.urls, item.URL)
|
||||
if place.pin < 0 {
|
||||
build.pinned[item.URL] = true
|
||||
}
|
||||
if place.pin == -100 {
|
||||
// Only the root Overview forces its section open.
|
||||
build.overview = true
|
||||
}
|
||||
} else {
|
||||
group, ok := build.groups[place.group]
|
||||
if !ok {
|
||||
group = &groupBuild{}
|
||||
build.groups[place.group] = group
|
||||
}
|
||||
group.items = append(group.items, item)
|
||||
group.urls = append(group.urls, item.URL)
|
||||
if place.pin < 0 {
|
||||
group.overview = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining sections
|
||||
for title, items := range categories {
|
||||
sections = append(sections, templates.NavSection{
|
||||
Title: title,
|
||||
Items: items,
|
||||
Collapsed: true,
|
||||
nav := &templates.Navigation{}
|
||||
for _, spec := range sectionOrder {
|
||||
build := builds[spec.Title]
|
||||
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
|
||||
|
||||
// Direct items: pinned landing pages lead, then alphabetical
|
||||
// (case-insensitive — KOReader must not sort before Kobo).
|
||||
items := append([]templates.NavItem(nil), build.items...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
|
||||
if pi != pj {
|
||||
return pi
|
||||
}
|
||||
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
|
||||
})
|
||||
}
|
||||
section.Items = items
|
||||
|
||||
return &templates.Navigation{Sections: sections}
|
||||
}
|
||||
|
||||
// createNavItem creates a navigation item from a document path
|
||||
func (h *DocsHandler) createNavItem(docPath string) templates.NavItem {
|
||||
title := h.getDocTitle(docPath)
|
||||
url := "/docs/" + strings.TrimSuffix(docPath, ".md")
|
||||
icon := h.getDocIcon(docPath)
|
||||
|
||||
return templates.NavItem{
|
||||
Title: title,
|
||||
URL: url,
|
||||
Icon: icon,
|
||||
}
|
||||
}
|
||||
|
||||
// getDocTitle extracts a readable title from a document path
|
||||
func (h *DocsHandler) getDocTitle(docPath string) string {
|
||||
// Remove .md extension
|
||||
title := strings.TrimSuffix(docPath, ".md")
|
||||
|
||||
// Split by /
|
||||
parts := strings.Split(title, "/")
|
||||
|
||||
// Get the last part (filename)
|
||||
filename := parts[len(parts)-1]
|
||||
|
||||
// Convert to title case
|
||||
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
|
||||
|
||||
// Handle special cases
|
||||
switch filename {
|
||||
case "index.md":
|
||||
return "Index"
|
||||
case "user/user-guide.md":
|
||||
return "User Guide"
|
||||
case "developer/development.md":
|
||||
return "Developer Portal"
|
||||
case "operations/operations.md":
|
||||
return "Operations Guide"
|
||||
case "contributing/contributing.md":
|
||||
return "Contributing"
|
||||
case "developer/api-reference.md":
|
||||
return "API Reference"
|
||||
case "developer/api/api-reference.md":
|
||||
return "API Reference"
|
||||
case "developer/api/collections-api.md":
|
||||
return "Collections API"
|
||||
case "developer/websocket-api.md":
|
||||
return "WebSocket API"
|
||||
case "operations/troubleshooting.md":
|
||||
return "Troubleshooting"
|
||||
case "user/sync-guide.md":
|
||||
return "Sync Guide"
|
||||
case "user/devices/kobo-setup.md":
|
||||
return "Kobo Setup"
|
||||
case "user/devices/koreader-setup.md":
|
||||
return "KOReader Setup"
|
||||
case "DEVELOPMENT.md":
|
||||
return "Development Guide"
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
// getDocIcon returns an appropriate icon for a document
|
||||
func (h *DocsHandler) getDocIcon(docPath string) string {
|
||||
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
|
||||
return "🔌"
|
||||
}
|
||||
if strings.Contains(docPath, "device") {
|
||||
return "📱"
|
||||
}
|
||||
if strings.Contains(docPath, "sync") || strings.Contains(docPath, "SYNC") {
|
||||
return "🔄"
|
||||
}
|
||||
if strings.Contains(docPath, "contributing") || strings.Contains(docPath, "DEVELOPMENT") {
|
||||
return "🛠️"
|
||||
}
|
||||
if strings.Contains(docPath, "index") {
|
||||
return "📚"
|
||||
}
|
||||
if strings.Contains(docPath, "troubleshooting") {
|
||||
return "🔧"
|
||||
}
|
||||
return "📄"
|
||||
}
|
||||
|
||||
// getCategoryForNav determines the navigation category for a document
|
||||
func (h *DocsHandler) getCategoryForNav(docPath string) string {
|
||||
// API documentation
|
||||
if strings.Contains(docPath, "api") || strings.Contains(docPath, "API") {
|
||||
return "API Reference"
|
||||
}
|
||||
|
||||
// Device setup
|
||||
if strings.HasPrefix(docPath, "devices/") {
|
||||
return "Device Setup"
|
||||
}
|
||||
|
||||
// Contributing
|
||||
if strings.HasPrefix(docPath, "contributing/") {
|
||||
return "Contributing"
|
||||
}
|
||||
|
||||
// Core user documentation
|
||||
docName := strings.ToLower(docPath)
|
||||
switch {
|
||||
case strings.Contains(docName, "sync"):
|
||||
return "User Guide"
|
||||
case strings.Contains(docName, "troubleshoot"):
|
||||
return "Getting Started"
|
||||
case strings.Contains(docName, "index"):
|
||||
return "Getting Started"
|
||||
default:
|
||||
return "Getting Started"
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIEndpoints returns a list of API endpoints for the API documentation
|
||||
func (h *DocsHandler) GetAPIEndpoints() []APIEndpoint {
|
||||
// This would be better auto-generated from Go code comments
|
||||
// For now, return a static list based on your API
|
||||
endpoints := []APIEndpoint{
|
||||
// Authentication
|
||||
{Method: "POST", Path: "/api/auth/register", Category: "Authentication", Title: "Register User"},
|
||||
{Method: "POST", Path: "/api/auth/login", Category: "Authentication", Title: "Login"},
|
||||
{Method: "POST", Path: "/api/auth/refresh", Category: "Authentication", Title: "Refresh Token"},
|
||||
{Method: "POST", Path: "/api/auth/logout", Category: "Authentication", Title: "Logout"},
|
||||
|
||||
// Users
|
||||
{Method: "GET", Path: "/api/users/me", Category: "Users", Title: "Get Current User"},
|
||||
{Method: "PUT", Path: "/api/users/me/profile", Category: "Users", Title: "Update Profile"},
|
||||
{Method: "PUT", Path: "/api/users/me/theme", Category: "Users", Title: "Update Theme"},
|
||||
{Method: "PUT", Path: "/api/users/me/password", Category: "Users", Title: "Change Password"},
|
||||
|
||||
// Libraries
|
||||
{Method: "GET", Path: "/api/libraries/visible", Category: "Libraries", Title: "Get Visible Libraries"},
|
||||
{Method: "GET", Path: "/api/libraries/{id}", Category: "Libraries", Title: "Get Library Details"},
|
||||
{Method: "POST", Path: "/api/libraries", Category: "Libraries", Title: "Create Library"},
|
||||
|
||||
// Media Items
|
||||
{Method: "GET", Path: "/api/media-items", Category: "Media Items", Title: "List Media Items"},
|
||||
{Method: "GET", Path: "/api/media-items/{id}", Category: "Media Items", Title: "Get Media Item"},
|
||||
{Method: "POST", Path: "/api/media-items/search", Category: "Media Items", Title: "Search Media Items"},
|
||||
|
||||
// Progress
|
||||
{Method: "GET", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Get Reading Progress"},
|
||||
{Method: "PUT", Path: "/api/media-items/{id}/progress", Category: "Progress", Title: "Update Reading Progress"},
|
||||
|
||||
// Devices
|
||||
{Method: "POST", Path: "/api/devices/register", Category: "Devices", Title: "Register Device"},
|
||||
{Method: "GET", Path: "/api/devices", Category: "Devices", Title: "List User Devices"},
|
||||
{Method: "DELETE", Path: "/api/devices/{id}", Category: "Devices", Title: "Revoke Device"},
|
||||
}
|
||||
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
if endpoints[i].Category == endpoints[j].Category {
|
||||
return endpoints[i].Title < endpoints[j].Title
|
||||
// Groups: alphabetical, each group's items alphabetical with the
|
||||
// group's "Overview" (index page) first.
|
||||
groupTitles := make([]string, 0, len(build.groups))
|
||||
for title := range build.groups {
|
||||
groupTitles = append(groupTitles, title)
|
||||
}
|
||||
sort.Strings(groupTitles)
|
||||
for _, title := range groupTitles {
|
||||
group := build.groups[title]
|
||||
gItems := append([]templates.NavItem(nil), group.items...)
|
||||
sort.SliceStable(gItems, func(i, j int) bool {
|
||||
return strings.ToLower(gItems[i].Title) < strings.ToLower(gItems[j].Title)
|
||||
})
|
||||
if group.overview {
|
||||
sort.SliceStable(gItems, func(i, j int) bool { return gItems[i].Title == "Overview" })
|
||||
}
|
||||
section.Groups = append(section.Groups, templates.NavGroup{
|
||||
Title: title,
|
||||
Items: gItems,
|
||||
Collapsed: !containsURL(currentPath, group.urls),
|
||||
})
|
||||
}
|
||||
return endpoints[i].Category < endpoints[j].Category
|
||||
})
|
||||
|
||||
return endpoints
|
||||
if len(section.Items) == 0 && len(section.Groups) == 0 {
|
||||
continue
|
||||
}
|
||||
section.Collapsed = !containsURL(currentPath, build.urls)
|
||||
if section.Collapsed {
|
||||
for _, g := range section.Groups {
|
||||
if !g.Collapsed {
|
||||
section.Collapsed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if build.overview {
|
||||
section.Collapsed = false
|
||||
}
|
||||
nav.Sections = append(nav.Sections, section)
|
||||
}
|
||||
|
||||
// Nothing matches the current page (404, search landing): open the
|
||||
// first section so the sidebar doesn't render fully closed.
|
||||
anyOpen := false
|
||||
for _, s := range nav.Sections {
|
||||
if !s.Collapsed {
|
||||
anyOpen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyOpen && len(nav.Sections) > 0 {
|
||||
nav.Sections[0].Collapsed = false
|
||||
}
|
||||
|
||||
return nav
|
||||
}
|
||||
|
||||
type APIEndpoint struct {
|
||||
Method string
|
||||
Path string
|
||||
Category string
|
||||
Title string
|
||||
// containsURL reports whether currentPath is one of the given doc URLs.
|
||||
func containsURL(currentPath string, urls []string) bool {
|
||||
for _, u := range urls {
|
||||
if currentPath == u {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bookhoard/templates"
|
||||
)
|
||||
|
||||
func TestPlaceDoc(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
section string
|
||||
group string
|
||||
pinned bool
|
||||
}{
|
||||
{"index.md", "Getting Started", "", true},
|
||||
{"user/user-guide.md", "User Guide", "", true},
|
||||
{"user/dashboard.md", "User Guide", "", false},
|
||||
{"user/admin-guide.md", "User Guide", "Administration", false},
|
||||
{"user/devices/kobo-setup.md", "User Guide", "Devices", false},
|
||||
{"developer/api/api-reference.md", "API Reference", "", true},
|
||||
{"developer/api/dashboard.md", "API Reference", "", false},
|
||||
{"developer/api/authentication/login.md", "API Reference", "Authentication", false},
|
||||
{"developer/api/saved-filters/index.md", "API Reference", "Saved Filters", true},
|
||||
{"developer/testing.md", "Developer", "", false},
|
||||
{"developer/development.md", "Developer", "", true},
|
||||
{"developer/scanner/audio-metadata.md", "Developer", "Scanner", false},
|
||||
{"contract_pins.md", "Developer", "Reference", false},
|
||||
{"FRONTEND_INTEGRATION.md", "Developer", "Reference", false},
|
||||
{"development/calibre-opf-implementation.md", "Developer", "Reference", false},
|
||||
{"operations/operations.md", "Operations", "", true},
|
||||
{"operations/troubleshooting.md", "Operations", "", false},
|
||||
{"contributing/contributing.md", "Contributing", "", true},
|
||||
{"contributing/development.md", "Contributing", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := placeDoc(tt.path)
|
||||
if got.section != tt.section || got.group != tt.group {
|
||||
t.Errorf("placeDoc(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.path, got.section, got.group, tt.section, tt.group)
|
||||
}
|
||||
if (got.pin < 0) != tt.pinned {
|
||||
t.Errorf("placeDoc(%q) pin = %d, pinned want %v", tt.path, got.pin, tt.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocTitle(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"index.md": "Overview",
|
||||
"user/user-guide.md": "User Guide",
|
||||
"user/devices/koreader-setup.md": "KOReader Setup",
|
||||
"developer/development.md": "Developer Portal",
|
||||
"developer/api/api-reference.md": "Overview",
|
||||
"developer/api/users/update_profile.md": "Update Profile",
|
||||
"developer/api/book-matching/link_book.md": "Link Book",
|
||||
"operations/troubleshooting.md": "Troubleshooting",
|
||||
"contract_pins.md": "Contract Pins",
|
||||
}
|
||||
for path, want := range tests {
|
||||
if got := docTitle(path); got != want {
|
||||
t.Errorf("docTitle(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocURL(t *testing.T) {
|
||||
if got := docURL("index.md"); got != "/docs" {
|
||||
t.Errorf("docURL(index.md) = %q, want /docs", got)
|
||||
}
|
||||
if got := docURL("user/sync-guide.md"); got != "/docs/user/sync-guide" {
|
||||
t.Errorf("docURL = %q, want /docs/user/sync-guide", got)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestDocs creates a representative docs tree in a temp dir and returns
|
||||
// a handler over it.
|
||||
func buildTestDocs(t *testing.T) *DocsHandler {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
files := []string{
|
||||
"index.md",
|
||||
"user/user-guide.md",
|
||||
"user/dashboard.md",
|
||||
"user/admin-guide.md",
|
||||
"user/devices/kobo-setup.md",
|
||||
"user/devices/koreader-setup.md",
|
||||
"developer/api/api-reference.md",
|
||||
"developer/api/authentication/login.md",
|
||||
"developer/api/authentication/overview.md",
|
||||
"developer/api/saved-filters/index.md",
|
||||
"developer/testing.md",
|
||||
"operations/troubleshooting.md",
|
||||
"contributing/development.md",
|
||||
"contract_pins.md",
|
||||
}
|
||||
for _, f := range files {
|
||||
path := filepath.Join(dir, f)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("# Test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &DocsHandler{docsFS: os.DirFS(dir), docsPath: dir}
|
||||
}
|
||||
|
||||
func TestBuildNavigation(t *testing.T) {
|
||||
h := buildTestDocs(t)
|
||||
|
||||
// Current page: a device setup guide → User Guide section and its
|
||||
// Devices group must render expanded; everything else collapsed.
|
||||
nav := h.BuildNavigation("/docs/user/devices/kobo-setup")
|
||||
if len(nav.Sections) == 0 {
|
||||
t.Fatal("no sections built")
|
||||
}
|
||||
|
||||
wantOrder := []string{"Getting Started", "User Guide", "API Reference", "Developer", "Operations", "Contributing"}
|
||||
for i, want := range wantOrder {
|
||||
if nav.Sections[i].Title != want {
|
||||
t.Fatalf("section %d = %q, want %q", i, nav.Sections[i].Title, want)
|
||||
}
|
||||
}
|
||||
|
||||
getting := nav.Sections[0]
|
||||
if getting.Collapsed {
|
||||
t.Error("Getting Started should be expanded (root overview pinned)")
|
||||
}
|
||||
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
|
||||
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
|
||||
}
|
||||
|
||||
userGuide := nav.Sections[1]
|
||||
if userGuide.Collapsed {
|
||||
t.Error("User Guide should be expanded: it contains the current page")
|
||||
}
|
||||
if len(userGuide.Items) != 2 || userGuide.Items[0].Title != "User Guide" {
|
||||
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
|
||||
}
|
||||
var devices *templates.NavGroup
|
||||
for i := range userGuide.Groups {
|
||||
if userGuide.Groups[i].Title == "Devices" {
|
||||
devices = &userGuide.Groups[i]
|
||||
}
|
||||
}
|
||||
if devices == nil {
|
||||
t.Fatal("User Guide has no Devices group")
|
||||
}
|
||||
if devices.Collapsed {
|
||||
t.Error("Devices group should be expanded: it contains the current page")
|
||||
}
|
||||
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
|
||||
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
|
||||
}
|
||||
|
||||
// Everything else starts collapsed.
|
||||
for _, s := range nav.Sections[2:] {
|
||||
if !s.Collapsed {
|
||||
t.Errorf("section %q should be collapsed (current page is elsewhere)", s.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// API Reference: subdirectory groups with the index page as Overview.
|
||||
api := nav.Sections[2]
|
||||
if len(api.Groups) != 2 {
|
||||
t.Fatalf("API Reference groups = %d, want 2 (Authentication, Saved Filters)", len(api.Groups))
|
||||
}
|
||||
if api.Groups[0].Title != "Authentication" || len(api.Groups[0].Items) != 2 {
|
||||
t.Errorf("Authentication group = %+v", api.Groups[0])
|
||||
}
|
||||
saved := api.Groups[1]
|
||||
if saved.Title != "Saved Filters" || len(saved.Items) != 1 || saved.Items[0].Title != "Overview" {
|
||||
t.Errorf("Saved Filters group = %+v, want single Overview item", saved)
|
||||
}
|
||||
if !saved.Collapsed {
|
||||
t.Error("Saved Filters group should be collapsed on an unrelated page")
|
||||
}
|
||||
|
||||
// Stray root files land in Developer → Reference.
|
||||
dev := nav.Sections[3]
|
||||
if len(dev.Groups) != 1 || dev.Groups[0].Title != "Reference" {
|
||||
t.Errorf("Developer groups = %+v, want Reference", dev.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNavigationNothingCurrent(t *testing.T) {
|
||||
h := buildTestDocs(t)
|
||||
nav := h.BuildNavigation("/docs/nonexistent")
|
||||
open := 0
|
||||
for _, s := range nav.Sections {
|
||||
if !s.Collapsed {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if open != 1 {
|
||||
t.Errorf("expected exactly one section open on a 404 path, got %d", open)
|
||||
}
|
||||
if nav.Sections[0].Collapsed {
|
||||
t.Error("fallback should open the first section")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user