T12: Dodat docs viewer sa goldmark markdown renderovanjem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 12:42:11 +00:00
parent 633de945e4
commit 563abd8481
12 changed files with 736 additions and 1 deletions

View File

@ -0,0 +1,68 @@
# T12 Izveštaj: Dashboard — prikaz dokumentacije i CLAUDE.md
**Agent:** coder
**Model:** Opus
**Datum:** 2026-02-20
---
## Šta je urađeno
Dodat docs viewer na dashboard sa goldmark markdown renderovanjem.
### Novi fajlovi
| Fajl | Opis |
|------|------|
| `internal/server/docs.go` | Docs handleri, goldmark renderovanje, path traversal zaštita, link rewriting |
| `web/templates/docs-list.html` | Template za listu .md fajlova |
| `web/templates/docs-view.html` | Template za prikaz renderovanog markdowna sa breadcrumbs |
### Izmenjeni fajlovi
| Fajl | Izmena |
|------|--------|
| `internal/server/server.go` | GET /docs i GET /docs/*path rute |
| `internal/server/render.go` | renderDocsList(), renderDocsView(), novi templates u init() |
| `internal/server/server_test.go` | 9 novih testova, .md fajlovi u test setup |
| `web/static/style.css` | Nav, docs-container, docs-list, docs-content, breadcrumbs stilovi |
| `web/templates/layout.html` | Nav bar sa Kanban/Dokumenti linkovima |
| `go.mod` / `go.sum` | goldmark v1.7.16 |
### Endpointi
| Ruta | Opis |
|------|------|
| `GET /docs` | Lista svih .md fajlova u projektu |
| `GET /docs/{path}` | Renderovan markdown fajl sa breadcrumbs |
### Bezbednost
- Samo .md fajlovi (ostalo → 403)
- Path traversal zaštita (`../../etc/passwd` → 403)
- filepath.Clean + HasPrefix provera
### Markdown features
- goldmark sa Table i Strikethrough ekstenzijama
- Relativni .md linkovi se rewrite-uju u `/docs/` linkove
- Code blokovi, tabele, liste, blockquote — sve renderovano
### Novi testovi
```
TestDocsList PASS
TestDocsView_CLAUDE PASS
TestDocsView_NestedFile PASS
TestDocsView_PathTraversal PASS
TestDocsView_NonMarkdown PASS
TestDocsView_NotFound PASS
TestDocsView_HasBreadcrumbs PASS
TestRewriteLinksSimple PASS
TestRewriteLinksSimple_NestedDir PASS
```
### Ukupno projekat: 101 test, svi prolaze
- `go vet ./...` — čist
- `go build ./...` — prolazi

70
TASKS/review/T12.md Normal file
View File

@ -0,0 +1,70 @@
# T12: Dashboard — prikaz dokumentacije i CLAUDE.md
**Kreirao:** planer
**Datum:** 2026-02-20
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** T11
---
## Opis
Dodaj tab/sekciju na dashboard za prikaz dokumentacije.
Prikazuje CLAUDE.md renderovan kao HTML, sa klikabilnim referencama
koje otvaraju referencirane fajlove inline.
## Endpointi
```
GET /docs → lista svih .md fajlova (HTML)
GET /docs/{path...} → renderovan markdown fajl (HTML fragment)
```
## Kako radi
1. Sidebar ili tab "Dokumenti" pored Kanban boarda
2. Klik → prikaže CLAUDE.md renderovan kao HTML
3. Reference u tabeli (npr. `agents/coder/CLAUDE.md`) su linkovi
4. Klik na link → HTMX učita taj fajl: `hx-get="/docs/agents/coder/CLAUDE.md"`
5. Breadcrumb navigacija: CLAUDE.md > agents > coder > CLAUDE.md
6. Dugme "Nazad" vraća na prethodni fajl
## Markdown renderovanje
- Go library: `github.com/gomarkdown/markdown` ili `goldmark`
- Renderuj server-side u HTML
- Tabele, code blokovi, headeri — sve podržano
- Relativne putanje u linkovima → pretvaraju se u /docs/ linkove
## Fajlovi koji se prikazuju
Svi .md fajlovi u projektu:
- CLAUDE.md (glavni)
- agents/*/CLAUDE.md
- TASKS/*.md
- TASKS/reports/*.md
- README.md
## Bezbednost
- Samo .md fajlovi (ne dozvoli čitanje .go, .env itd.)
- Samo unutar project root-a (ne dozvoli ../../../etc/passwd)
- Path traversal zaštita
## Testovi
- GET /docs → lista fajlova
- GET /docs/CLAUDE.md → renderovan HTML
- GET /docs/agents/coder/CLAUDE.md → renderovan HTML
- GET /docs/../../etc/passwd → 403
- GET /docs/main.go → 403 (nije .md)
- Klikabilni linkovi u renderovanom HTML-u
---
## Pitanja
---
## Odgovori

View File

@ -26,6 +26,7 @@ require (
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect

View File

@ -61,6 +61,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=

View File

@ -0,0 +1,208 @@
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
}

View File

@ -58,6 +58,8 @@ func init() {
web.TemplatesFS,
"templates/layout.html",
"templates/dashboard.html",
"templates/docs-list.html",
"templates/docs-view.html",
"templates/partials/column.html",
"templates/partials/task-card.html",
"templates/partials/task-detail.html",
@ -86,6 +88,24 @@ func renderDashboard(columns map[string][]supervisor.Task) string {
return buf.String()
}
// renderDocsList generates the docs listing HTML page.
func renderDocsList(data docsListData) string {
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, "docs-list", data); err != nil {
return "Greška pri renderovanju: " + err.Error()
}
return buf.String()
}
// renderDocsView generates the docs view HTML page.
func renderDocsView(data docsViewData) string {
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, "docs-view", data); err != nil {
return "Greška pri renderovanju: " + err.Error()
}
return buf.String()
}
// renderTaskDetail generates HTML fragment for task detail panel.
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
data := taskDetailData{

View File

@ -96,6 +96,10 @@ func (s *Server) setupRoutes() {
s.Router.GET("/task/:id", s.handleTaskDetail)
s.Router.POST("/task/:id/move", s.handleMoveTask)
s.Router.GET("/report/:id", s.handleReport)
// Docs routes
s.Router.GET("/docs", s.handleDocsList)
s.Router.GET("/docs/*path", s.handleDocsView)
}
// apiGetTasks returns all tasks as JSON.

View File

@ -53,6 +53,12 @@ func setupTestServer(t *testing.T) *Server {
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
// Docs: create markdown files in project root
os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644)
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
cfg := &config.Config{
TasksDir: tasksDir,
ProjectPath: dir,
@ -548,6 +554,142 @@ func TestDashboardReflectsDiskChanges(t *testing.T) {
}
}
func TestDocsList(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "CLAUDE.md") {
t.Error("expected CLAUDE.md in docs list")
}
if !containsStr(body, "README.md") {
t.Error("expected README.md in docs list")
}
if !containsStr(body, "agents/coder/CLAUDE.md") {
t.Error("expected agents/coder/CLAUDE.md in docs list")
}
}
func TestDocsView_CLAUDE(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "Glavni fajl") {
t.Error("expected rendered markdown content")
}
// Should have table rendered as HTML
if !containsStr(body, "<table>") || !containsStr(body, "<th>") {
t.Error("expected HTML table from markdown")
}
}
func TestDocsView_NestedFile(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "Coder Agent") {
t.Error("expected nested file content")
}
// Breadcrumbs
if !containsStr(body, "agents") {
t.Error("expected breadcrumb for agents")
}
}
func TestDocsView_PathTraversal(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for path traversal, got %d", w.Code)
}
}
func TestDocsView_NonMarkdown(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-.md file, got %d", w.Code)
}
}
func TestDocsView_NotFound(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestDocsView_HasBreadcrumbs(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Dokumenti") {
t.Error("expected 'Dokumenti' in breadcrumbs")
}
if !containsStr(body, "coder") {
t.Error("expected 'coder' in breadcrumbs")
}
}
func TestRewriteLinksSimple(t *testing.T) {
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
result := rewriteLinksSimple(input, ".")
if !containsStr(result, `/docs/README.md`) {
t.Errorf("expected rewritten link, got: %s", result)
}
if !containsStr(result, `https://example.com`) {
t.Error("external link should not be rewritten")
}
}
func TestRewriteLinksSimple_NestedDir(t *testing.T) {
input := `<a href="CLAUDE.md">link</a>`
result := rewriteLinksSimple(input, "agents/coder")
if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) {
t.Errorf("expected nested rewritten link, got: %s", result)
}
}
func containsStr(s, substr string) bool {
return len(s) >= len(substr) && findStr(s, substr)
}

View File

@ -226,6 +226,157 @@ body {
color: #fff;
}
/* Navigation */
.nav {
display: flex;
gap: 8px;
align-items: center;
}
.btn-active {
background: #0f3460;
border-color: #e94560;
}
/* Docs */
.docs-container {
padding: 16px 24px;
max-width: 960px;
margin: 0 auto;
}
.docs-container h2 {
margin-bottom: 16px;
color: #e94560;
}
.docs-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.doc-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #16213e;
border-radius: 6px;
color: #eee;
text-decoration: none;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
transition: background 0.2s;
}
.doc-item:hover {
background: #0f3460;
}
.doc-icon { font-size: 1em; }
.docs-breadcrumbs {
margin-bottom: 16px;
font-size: 0.85em;
}
.docs-breadcrumbs a {
color: #e94560;
text-decoration: none;
}
.docs-breadcrumbs a:hover {
text-decoration: underline;
}
.breadcrumb-sep {
color: #555;
margin: 0 4px;
}
.docs-content {
background: #16213e;
border-radius: 8px;
padding: 24px;
line-height: 1.7;
font-size: 0.95em;
}
.docs-content h1, .docs-content h2, .docs-content h3 {
color: #e94560;
margin-top: 1.2em;
margin-bottom: 0.5em;
}
.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid #333; padding-bottom: 8px; }
.docs-content h2 { font-size: 1.2em; }
.docs-content h3 { font-size: 1.05em; }
.docs-content a {
color: #4ecca3;
text-decoration: none;
}
.docs-content a:hover { text-decoration: underline; }
.docs-content code {
background: #1a1a2e;
padding: 2px 6px;
border-radius: 3px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.9em;
}
.docs-content pre {
background: #1a1a2e;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
}
.docs-content pre code {
background: none;
padding: 0;
}
.docs-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.docs-content th, .docs-content td {
border: 1px solid #333;
padding: 8px 12px;
text-align: left;
}
.docs-content th {
background: #0f3460;
}
.docs-content ul, .docs-content ol {
padding-left: 24px;
margin: 8px 0;
}
.docs-content li { margin: 4px 0; }
.docs-content blockquote {
border-left: 3px solid #e94560;
padding-left: 12px;
color: #aaa;
margin: 12px 0;
}
.docs-content hr {
border: none;
border-top: 1px solid #333;
margin: 16px 0;
}
/* Responsive */
@media (max-width: 1100px) {
.board { grid-template-columns: repeat(3, 1fr); }

View File

@ -0,0 +1,33 @@
{{define "docs-list"}}
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KAOS — Dokumenti</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<nav class="nav">
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn btn-active">Dokumenti</a>
</nav>
</div>
<div class="docs-container">
<h2>Dokumentacija</h2>
<div class="docs-list">
{{range .Files}}
<a href="/docs/{{.Path}}" class="doc-item" hx-get="/docs/{{.Path}}" hx-target="#docs-content" hx-push-url="true">
<span class="doc-icon">📄</span>
<span class="doc-name">{{.Name}}</span>
</a>
{{end}}
</div>
<div id="docs-content"></div>
</div>
</body>
</html>
{{end}}

View File

@ -0,0 +1,33 @@
{{define "docs-view"}}
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KAOS — {{.Path}}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<nav class="nav">
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn btn-active">Dokumenti</a>
</nav>
</div>
<div class="docs-container">
<div class="docs-breadcrumbs">
<a href="/docs">Dokumenti</a>
{{range .Breadcrumbs}}
<span class="breadcrumb-sep"></span>
<a href="/docs/{{.Path}}">{{.Name}}</a>
{{end}}
</div>
<div class="docs-content" id="docs-content">
{{.HTML}}
</div>
</div>
</body>
</html>
{{end}}

View File

@ -11,7 +11,10 @@
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<span class="version">v0.2</span>
<nav class="nav">
<a href="/" class="btn btn-active">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
</nav>
</div>
{{block "content" .}}{{end}}
<div id="task-detail"></div>