package server import ( "bytes" htmltpl "html/template" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" ) // docFile represents a markdown file in the file listing. type docFile struct { Path string // relative path from project root Name string // display name } // docsListData holds data for the docs listing page. type docsListData struct { Files []docFile } // docsViewData holds data for viewing a single doc. type docsViewData struct { Path string Breadcrumbs []breadcrumb HTML htmltpl.HTML Files []docFile } // breadcrumb represents one segment of the navigation path. type breadcrumb struct { Name string Path string } // md is the goldmark markdown renderer. var md = goldmark.New( goldmark.WithExtensions(extension.Table, extension.Strikethrough), goldmark.WithRendererOptions(html.WithUnsafe()), ) // projectRoot returns the KAOS project root derived from TasksDir. func (s *Server) projectRoot() string { return filepath.Dir(s.Config.TasksDir) } // handleDocsList serves the docs listing page. func (s *Server) handleDocsList(c *gin.Context) { root := s.projectRoot() files := scanMarkdownFiles(root) c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderDocsList(docsListData{Files: files})) } // handleDocsView serves a rendered markdown file. func (s *Server) handleDocsView(c *gin.Context) { relPath := c.Param("path") // Strip leading slash from wildcard param relPath = strings.TrimPrefix(relPath, "/") root := s.projectRoot() // Security: only .md files if !strings.HasSuffix(relPath, ".md") { c.String(http.StatusForbidden, "Samo .md fajlovi su dozvoljeni") return } // Security: resolve and check path traversal absPath := filepath.Join(root, relPath) absPath = filepath.Clean(absPath) if !strings.HasPrefix(absPath, filepath.Clean(root)+string(filepath.Separator)) { c.String(http.StatusForbidden, "Pristup odbijen") return } content, err := os.ReadFile(absPath) if err != nil { c.String(http.StatusNotFound, "Fajl nije pronađen: %s", relPath) return } // Render markdown to HTML rendered := renderMarkdown(content, relPath) // HTMX request → return just the content fragment if c.GetHeader("HX-Request") == "true" { c.Header("Content-Type", "text/html; charset=utf-8") breadcrumbHTML := `
` c.String(http.StatusOK, breadcrumbHTML+rendered) return } // Full page request → return with sidebar data := docsViewData{ Path: relPath, Breadcrumbs: buildBreadcrumbs(relPath), HTML: htmltpl.HTML(rendered), Files: scanMarkdownFiles(s.projectRoot()), } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderDocsView(data)) } // scanMarkdownFiles walks the project root and returns all .md files. func scanMarkdownFiles(root string) []docFile { var files []docFile filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } // Skip hidden dirs, code/vendor, .git name := info.Name() if info.IsDir() { if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" { return filepath.SkipDir } return nil } if !strings.HasSuffix(name, ".md") { return nil } rel, _ := filepath.Rel(root, path) files = append(files, docFile{ Path: rel, Name: rel, }) return nil }) return files } // renderMarkdown converts markdown bytes to HTML, rewriting relative .md links to /docs/ links. func renderMarkdown(source []byte, docPath string) string { var buf bytes.Buffer if err := md.Convert(source, &buf); err != nil { return "Greška pri renderovanju: " + err.Error() + "
" } html := buf.String() // Rewrite relative .md links to /docs/ links // Find href="something.md" or href="path/to/file.md" patterns html = rewriteMarkdownLinks(html, docPath) return html } // rewriteMarkdownLinks converts relative .md hrefs to /docs/ prefixed paths. func rewriteMarkdownLinks(htmlContent, currentPath string) string { dir := filepath.Dir(currentPath) return rewriteLinksSimple(htmlContent, dir) } // rewriteLinksSimple does a single pass to rewrite .md links. func rewriteLinksSimple(htmlContent, baseDir string) string { var result strings.Builder remaining := htmlContent for { idx := strings.Index(remaining, `href="`) if idx == -1 { result.WriteString(remaining) break } result.WriteString(remaining[:idx+6]) remaining = remaining[idx+6:] endQuote := strings.Index(remaining, `"`) if endQuote == -1 { result.WriteString(remaining) break } href := remaining[:endQuote] remaining = remaining[endQuote:] if strings.HasSuffix(href, ".md") && !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/") { if baseDir == "." || baseDir == "" { result.WriteString("/docs/" + href) } else { result.WriteString("/docs/" + filepath.Join(baseDir, href)) } } else { result.WriteString(href) } } return result.String() } // buildBreadcrumbs creates navigation breadcrumbs from a file path. func buildBreadcrumbs(path string) []breadcrumb { parts := strings.Split(path, "/") crumbs := make([]breadcrumb, len(parts)) for i, part := range parts { crumbs[i] = breadcrumb{ Name: part, Path: strings.Join(parts[:i+1], "/"), } } return crumbs }