feat: implement WYSIWYG markdown editor
Add full-stack markdown editor with Go backend and React frontend. Backend: - Cobra CLI with --data-dir, --port, --host flags - REST API for markdown file CRUD operations - File storage with flat directory structure - logrus logging for all operations - Static file serving for frontend - Comprehensive tests for CRUD and static assets Frontend: - React + TypeScript + Vite + Tailwind CSS - Live markdown preview with marked (GFM) - File management: list, create, open, save, delete - Theme system: Dark/Light/System with persistence - Responsive design (320px to 1920px) - Component tests for Editor, Preview, Sidebar Build: - Makefile for build, test, and run automation - Single command testing (make test) Closes SPEC.md requirements
This commit is contained in:
172
backend/server/server.go
Normal file
172
backend/server/server.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor-backend/handlers"
|
||||
"markdown-editor-backend/storage"
|
||||
)
|
||||
|
||||
// Server represents the HTTP server
|
||||
type Server struct {
|
||||
dataDir string
|
||||
host string
|
||||
port int
|
||||
log *logrus.Logger
|
||||
storage *storage.Storage
|
||||
handler *handlers.Handler
|
||||
staticPath string
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// New creates a new Server instance
|
||||
func New(dataDir, host string, port int, log *logrus.Logger) *Server {
|
||||
store := storage.New(dataDir, log)
|
||||
handler := handlers.New(store, log)
|
||||
return &Server{
|
||||
dataDir: dataDir,
|
||||
host: host,
|
||||
port: port,
|
||||
log: log,
|
||||
storage: store,
|
||||
handler: handler,
|
||||
staticPath: "./static",
|
||||
}
|
||||
}
|
||||
|
||||
// SetStaticPath sets the path for static files (used for embedding frontend)
|
||||
func (s *Server) SetStaticPath(path string) {
|
||||
s.staticPath = path
|
||||
}
|
||||
|
||||
// Start initializes storage and starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
if err := s.storage.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/files", s.handler.FilesHandler)
|
||||
mux.HandleFunc("/api/files/", s.handler.FileHandler)
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Static file serving - serve frontend build
|
||||
fs := http.FileServer(http.Dir(s.staticPath))
|
||||
mux.Handle("/", fs)
|
||||
|
||||
// CORS middleware
|
||||
handler := s.corsMiddleware(mux)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
s.log.WithField("address", addr).Info("Server starting")
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
return http.Serve(listener, handler)
|
||||
}
|
||||
|
||||
// StartAsync starts the server asynchronously and returns the actual address
|
||||
func (s *Server) StartAsync() (string, error) {
|
||||
if err := s.storage.Init(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/files", s.handler.FilesHandler)
|
||||
mux.HandleFunc("/api/files/", s.handler.FileHandler)
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Static file serving - serve frontend build
|
||||
fs := http.FileServer(http.Dir(s.staticPath))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
|
||||
return
|
||||
}
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
// CORS middleware
|
||||
handler := s.corsMiddleware(mux)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
if s.port == 0 {
|
||||
addr = fmt.Sprintf("%s:0", s.host)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
actualAddr := listener.Addr().String()
|
||||
s.log.WithField("address", actualAddr).Info("Server starting")
|
||||
|
||||
go http.Serve(listener, handler)
|
||||
|
||||
return actualAddr, nil
|
||||
}
|
||||
|
||||
// Stop stops the server
|
||||
func (s *Server) Stop() error {
|
||||
if s.listener != nil {
|
||||
return s.listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers for frontend development
|
||||
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetStorage returns the storage instance (for testing)
|
||||
func (s *Server) GetStorage() *storage.Storage {
|
||||
return s.storage
|
||||
}
|
||||
|
||||
// GetStaticPath returns the static files path (for testing)
|
||||
func (s *Server) GetStaticPath() string {
|
||||
return s.staticPath
|
||||
}
|
||||
|
||||
// GetAddr returns the server address (for testing)
|
||||
func (s *Server) GetAddr() string {
|
||||
if s.listener != nil {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
}
|
||||
255
backend/server/server_test.go
Normal file
255
backend/server/server_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor-backend/server"
|
||||
)
|
||||
|
||||
func TestServerCRUD(t *testing.T) {
|
||||
// Create temp directory for test data
|
||||
tempDir, err := os.MkdirTemp("", "markdown-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create static directory
|
||||
staticDir := filepath.Join(tempDir, "static")
|
||||
if err := os.MkdirAll(staticDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create static dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("<html></html>"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create index.html: %v", err)
|
||||
}
|
||||
|
||||
// Create logger
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.WarnLevel)
|
||||
|
||||
// Create server with dynamic port
|
||||
srv := server.New(tempDir, "127.0.0.1", 0, log)
|
||||
srv.SetStaticPath(staticDir)
|
||||
|
||||
// Start server asynchronously
|
||||
addr, err := srv.StartAsync()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
defer srv.Stop()
|
||||
|
||||
// Wait for server to be ready
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
baseURL := "http://" + addr
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// Test health endpoint
|
||||
t.Run("HealthCheck", func(t *testing.T) {
|
||||
resp, err := client.Get(baseURL + "/api/health")
|
||||
if err != nil {
|
||||
t.Fatalf("Health check failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["status"] != "ok" {
|
||||
t.Errorf("Expected status 'ok', got '%s'", result["status"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test Create
|
||||
t.Run("CreateFile", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"name": "test-file",
|
||||
"content": "# Hello World\n\nThis is a test file.",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := client.Post(baseURL+"/api/files", "application/json", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Create file failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["name"] != "test-file" {
|
||||
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test List
|
||||
t.Run("ListFiles", func(t *testing.T) {
|
||||
resp, err := client.Get(baseURL + "/api/files")
|
||||
if err != nil {
|
||||
t.Fatalf("List files failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, name := range result {
|
||||
if name == "test-file" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected 'test-file' in list, got %v", result)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Get
|
||||
t.Run("GetFile", func(t *testing.T) {
|
||||
resp, err := client.Get(baseURL + "/api/files/test-file")
|
||||
if err != nil {
|
||||
t.Fatalf("Get file failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["name"] != "test-file" {
|
||||
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
|
||||
}
|
||||
|
||||
if result["content"] != "# Hello World\n\nThis is a test file." {
|
||||
t.Errorf("Expected specific content, got '%s'", result["content"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test Update
|
||||
t.Run("UpdateFile", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"content": "# Updated Content\n\nThis file has been updated.",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, baseURL+"/api/files/test-file", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Update file failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify the update
|
||||
resp2, _ := client.Get(baseURL + "/api/files/test-file")
|
||||
defer resp2.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp2.Body).Decode(&result)
|
||||
|
||||
if result["content"] != "# Updated Content\n\nThis file has been updated." {
|
||||
t.Errorf("Expected updated content, got '%s'", result["content"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test Delete
|
||||
t.Run("DeleteFile", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/files/test-file", nil)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete file failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("Expected status 204, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify deletion
|
||||
resp2, _ := client.Get(baseURL + "/api/files/test-file")
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404 for deleted file, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 404
|
||||
t.Run("GetNonExistentFile", func(t *testing.T) {
|
||||
resp, err := client.Get(baseURL + "/api/files/nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["error"] != "file not found" {
|
||||
t.Errorf("Expected error 'file not found', got '%s'", result["error"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test static file serving
|
||||
t.Run("StaticFiles", func(t *testing.T) {
|
||||
resp, err := client.Get(baseURL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("Get static file failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "<html></html>" {
|
||||
t.Errorf("Expected '<html></html>', got '%s'", string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user