224 lines
5.6 KiB
Go
224 lines
5.6 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
|
||
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
|
||
}
|