Remove redundant type conversions that Go 1.26 makes unnecessary or that
were already no-ops:
- uuid.UUID(x.Bytes) → x.Bytes (uuid.UUID is [16]byte, same as pgtype UUID Bytes)
- pgtype.UUID{Bytes: [16]byte(u), Valid: true} → pgtype.UUID{Bytes: u, Valid: true}
- (*time.Time)(&x.Time) → &x.Time
- json.RawMessage(x) → x where x is already []byte
- []byte(stringVal) → stringVal where []byte is expected
- int()/int64()/byte() casts on values already of the target type
- Decompressor(fn) → fn (type is identical)
Handle previously ignored error returns:
- collections.go: check json.Unmarshal error in GetCollection
- conversion_service.go: check fileSize.Scan() error
- app_test.go: check app.Shutdown() error in benchmark
420 lines
10 KiB
Go
420 lines
10 KiB
Go
package docs
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"strings"
|
|
|
|
"bookhoard/templates"
|
|
|
|
"github.com/yuin/goldmark"
|
|
highlighting "github.com/yuin/goldmark-highlighting"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
type DocsHandler struct {
|
|
markdown goldmark.Markdown
|
|
docsPath string
|
|
docsFS fs.FS
|
|
}
|
|
|
|
func NewDocsHandler(docsPath string) *DocsHandler {
|
|
// Create goldmark renderer with syntax highlighting
|
|
md := goldmark.New(
|
|
goldmark.WithExtensions(
|
|
extension.GFM,
|
|
extension.Table,
|
|
highlighting.NewHighlighting(
|
|
highlighting.WithStyle("github"),
|
|
),
|
|
),
|
|
goldmark.WithParserOptions(
|
|
parser.WithAutoHeadingID(),
|
|
),
|
|
goldmark.WithRendererOptions(
|
|
html.WithHardWraps(),
|
|
html.WithXHTML(),
|
|
html.WithUnsafe(),
|
|
),
|
|
)
|
|
|
|
return &DocsHandler{
|
|
markdown: md,
|
|
docsPath: docsPath,
|
|
docsFS: os.DirFS(docsPath),
|
|
}
|
|
}
|
|
|
|
// LoadDocument loads and renders a markdown document
|
|
func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error) {
|
|
// Clean the path
|
|
docPath = strings.TrimPrefix(docPath, "/")
|
|
docPath = strings.TrimSuffix(docPath, "/")
|
|
|
|
// Default to INDEX.md if root
|
|
if docPath == "" || docPath == "docs" {
|
|
docPath = "index.md"
|
|
} else {
|
|
// Remove /docs prefix if present
|
|
docPath = strings.TrimPrefix(docPath, "docs/")
|
|
|
|
// Add .md if not present
|
|
if !strings.HasSuffix(docPath, ".md") {
|
|
docPath += ".md"
|
|
}
|
|
}
|
|
|
|
// Read the markdown file
|
|
content, err := fs.ReadFile(h.docsFS, docPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read document: %w", err)
|
|
}
|
|
|
|
// Convert markdown to HTML
|
|
var buf bytes.Buffer
|
|
context := parser.NewContext()
|
|
if err := h.markdown.Convert(content, &buf, parser.WithContext(context)); err != nil {
|
|
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)
|
|
|
|
// Generate table of contents
|
|
toc := h.generateTOC(content)
|
|
|
|
// Generate breadcrumb
|
|
breadcrumb := h.generateBreadcrumb(docPath)
|
|
|
|
// Determine category
|
|
category := h.getCategory(docPath)
|
|
|
|
return &templates.Document{
|
|
Title: title,
|
|
Content: buf.String(),
|
|
TOC: toc,
|
|
Breadcrumb: breadcrumb,
|
|
Category: category,
|
|
SourceFile: docPath,
|
|
}, nil
|
|
}
|
|
|
|
// extractTitle extracts the first heading from markdown content
|
|
func (h *DocsHandler) extractTitle(content []byte) string {
|
|
lines := bytes.Split(content, []byte("\n"))
|
|
for _, line := range lines {
|
|
line = bytes.TrimSpace(line)
|
|
if bytes.HasPrefix(line, []byte("# ")) {
|
|
return string(bytes.TrimPrefix(line, []byte("# ")))
|
|
}
|
|
}
|
|
return "Documentation"
|
|
}
|
|
|
|
// generateTOC generates a table of contents from markdown headings
|
|
func (h *DocsHandler) generateTOC(content []byte) []templates.TOCItem {
|
|
var toc []templates.TOCItem
|
|
lines := bytes.Split(content, []byte("\n"))
|
|
|
|
for _, line := range lines {
|
|
line = bytes.TrimSpace(line)
|
|
if bytes.HasPrefix(line, []byte("#")) && !bytes.HasPrefix(line, []byte("# ")) {
|
|
// Count heading level
|
|
level := 0
|
|
for _, c := range line {
|
|
if c == '#' {
|
|
level++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if level > 1 && level <= 4 { // Only ## ### ####
|
|
title := strings.TrimSpace(string(bytes.TrimLeft(line, "#")))
|
|
anchor := h.slugify(title)
|
|
|
|
toc = append(toc, templates.TOCItem{
|
|
Level: level,
|
|
Title: title,
|
|
Anchor: anchor,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return toc
|
|
}
|
|
|
|
// generateBreadcrumb generates breadcrumb navigation
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
return breadcrumb
|
|
}
|
|
|
|
// getCategory determines the category of a document
|
|
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"
|
|
}
|
|
|
|
// slugify converts a string to a URL-safe slug
|
|
func (h *DocsHandler) slugify(s string) string {
|
|
s = strings.ToLower(s)
|
|
s = strings.ReplaceAll(s, " ", "-")
|
|
s = strings.ReplaceAll(s, "/", "-")
|
|
|
|
// Remove non-alphanumeric characters (except hyphens)
|
|
var result strings.Builder
|
|
for _, c := range s {
|
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
|
|
result.WriteRune(c)
|
|
}
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
// ListDocuments returns all markdown files in the docs directory
|
|
func (h *DocsHandler) ListDocuments() ([]string, error) {
|
|
var docs []string
|
|
|
|
err := fs.WalkDir(h.docsFS, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, ".md") {
|
|
docs = append(docs, strings.TrimSuffix(path, ".md"))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
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"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// GenerateSearchIndex generates a search index for all documentation
|
|
func (h *DocsHandler) GenerateSearchIndex() ([]SearchDoc, error) {
|
|
docs, err := h.ListDocuments()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list documents: %w", err)
|
|
}
|
|
|
|
var searchDocs []SearchDoc
|
|
for _, path := range docs {
|
|
doc, err := h.LoadDocument(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Strip HTML tags for better search
|
|
content := h.stripHTML(doc.Content)
|
|
|
|
searchDocs = append(searchDocs, SearchDoc{
|
|
ID: path,
|
|
Title: doc.Title,
|
|
Content: content,
|
|
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
|
|
})
|
|
}
|
|
|
|
return searchDocs, nil
|
|
}
|
|
|
|
// stripHTML removes HTML tags from string
|
|
func (h *DocsHandler) stripHTML(html string) string {
|
|
var result strings.Builder
|
|
inTag := false
|
|
for _, r := range html {
|
|
if r == '<' {
|
|
inTag = true
|
|
continue
|
|
}
|
|
if r == '>' {
|
|
inTag = false
|
|
continue
|
|
}
|
|
if !inTag {
|
|
result.WriteRune(r)
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|