KAOS/code/internal/server/docs.go
djuka 563abd8481 T12: Dodat docs viewer sa goldmark markdown renderovanjem
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:42:11 +00:00

209 lines
5.0 KiB
Go

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
}
// 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)
data := docsViewData{
Path: relPath,
Breadcrumbs: buildBreadcrumbs(relPath),
HTML: htmltpl.HTML(rendered),
}
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 "<p>Greška pri renderovanju: " + err.Error() + "</p>"
}
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
}