KAOS/code/internal/server/docs.go
djuka 0e6d0ecd66 T15: Docs viewer sidebar layout (25%/75% grid) sa HTMX fragmentima
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:55:05 +00:00

224 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 := `<div class="docs-breadcrumbs"><a href="/docs">Dokumenti</a>`
for _, bc := range buildBreadcrumbs(relPath) {
breadcrumbHTML += ` <span class="breadcrumb-sep"></span> <a href="/docs/` + bc.Path + `">` + bc.Name + `</a>`
}
breadcrumbHTML += `</div>`
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 "<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
}