package docs import ( "bytes" "fmt" "io/fs" "os" "strings" "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" "bookhoard/templates" ) 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([]byte(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: strings.Title(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() }