T12: Dodat docs viewer sa goldmark markdown renderovanjem
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
633de945e4
commit
563abd8481
68
TASKS/reports/T12-report.md
Normal file
68
TASKS/reports/T12-report.md
Normal 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
70
TASKS/review/T12.md
Normal 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
|
||||||
@ -26,6 +26,7 @@ require (
|
|||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // 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
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
|||||||
@ -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/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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
|||||||
208
code/internal/server/docs.go
Normal file
208
code/internal/server/docs.go
Normal 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
|
||||||
|
}
|
||||||
@ -58,6 +58,8 @@ func init() {
|
|||||||
web.TemplatesFS,
|
web.TemplatesFS,
|
||||||
"templates/layout.html",
|
"templates/layout.html",
|
||||||
"templates/dashboard.html",
|
"templates/dashboard.html",
|
||||||
|
"templates/docs-list.html",
|
||||||
|
"templates/docs-view.html",
|
||||||
"templates/partials/column.html",
|
"templates/partials/column.html",
|
||||||
"templates/partials/task-card.html",
|
"templates/partials/task-card.html",
|
||||||
"templates/partials/task-detail.html",
|
"templates/partials/task-detail.html",
|
||||||
@ -86,6 +88,24 @@ func renderDashboard(columns map[string][]supervisor.Task) string {
|
|||||||
return buf.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.
|
// renderTaskDetail generates HTML fragment for task detail panel.
|
||||||
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
||||||
data := taskDetailData{
|
data := taskDetailData{
|
||||||
|
|||||||
@ -96,6 +96,10 @@ func (s *Server) setupRoutes() {
|
|||||||
s.Router.GET("/task/:id", s.handleTaskDetail)
|
s.Router.GET("/task/:id", s.handleTaskDetail)
|
||||||
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
||||||
s.Router.GET("/report/:id", s.handleReport)
|
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.
|
// apiGetTasks returns all tasks as JSON.
|
||||||
|
|||||||
@ -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, "done", "T01.md"), []byte(testTask1), 0644)
|
||||||
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 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{
|
cfg := &config.Config{
|
||||||
TasksDir: tasksDir,
|
TasksDir: tasksDir,
|
||||||
ProjectPath: dir,
|
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 {
|
func containsStr(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && findStr(s, substr)
|
return len(s) >= len(substr) && findStr(s, substr)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,6 +226,157 @@ body {
|
|||||||
color: #fff;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.board { grid-template-columns: repeat(3, 1fr); }
|
.board { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|||||||
33
code/web/templates/docs-list.html
Normal file
33
code/web/templates/docs-list.html
Normal 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}}
|
||||||
33
code/web/templates/docs-view.html
Normal file
33
code/web/templates/docs-view.html
Normal 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}}
|
||||||
@ -11,7 +11,10 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🔧 KAOS Dashboard</h1>
|
<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>
|
</div>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
<div id="task-detail"></div>
|
<div id="task-detail"></div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user