feat(markdown-editor): implement wysiswyg markdown editor with live preview

- Build Go backend with Cobra CLI and REST API
  - CRUD operations for markdown files (GET, POST, PUT, DELETE)
  - File storage with flat .md file structure
  - Comprehensive logrus logging with JSON format
  - Static asset serving for frontend

- Build React/TypeScript frontend with Tailwind CSS
  - Markdown editor with live GFM preview
  - File management UI (list, create, open, delete)
  - Theme system (Dark/Light/System) with persistence
  - Responsive design (320px mobile, 1920px desktop)

- Add comprehensive test coverage
  - Backend: API, storage, and logger tests (13 tests passing)
  - Frontend: Editor and App component tests

- Setup Nix development environment with Go, Node.js, and TypeScript
This commit is contained in:
2026-02-05 17:48:23 -05:00
parent 78f33053fb
commit 5b67cb61d2
31 changed files with 2010 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/markdown-editor/internal/storage"
"github.com/markdown-editor/pkg/logger"
)
type bytesReader struct {
bytes []byte
}
func (r *bytesReader) Read(p []byte) (n int, err error) {
if len(r.bytes) == 0 {
return 0, io.EOF
}
n = copy(p, r.bytes)
r.bytes = r.bytes[n:]
return n, nil
}
func (r *bytesReader) Close() error {
return nil
}
func setupTestServer(t *testing.T) (*Server, string) {
// Initialize logger
logger.Init()
// Create temporary directory for test data
tempDir := t.TempDir()
// Initialize storage
s := storage.NewStorage(tempDir)
// Create server
server, err := NewServer(tempDir, "127.0.0.1", 0)
if err != nil {
t.Fatalf("Failed to create server: %v", err)
}
// Get actual port assigned by ListenAndServe
router := http.NewServeMux()
router.HandleFunc("/api/files", server.handleFiles)
router.HandleFunc("/api/files/", server.handleFiles)
router.HandleFunc("/", server.handleFrontend)
server.storage = s
server.port = 0 // 0 means assign any available port
return server, tempDir
}
func TestHandleGetFiles(t *testing.T) {
server, dataDir := setupTestServer(t)
// Create test files
content := "Test content"
testFiles := []string{"test1.md", "test2.md"}
for _, filename := range testFiles {
path := filepath.Join(dataDir, filename)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
}
req := httptest.NewRequest("GET", "/api/files", nil)
w := httptest.NewRecorder()
server.handleGetFiles(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var files []string
if err := json.NewDecoder(w.Body).Decode(&files); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(files) != 2 {
t.Errorf("Expected 2 files, got %d", len(files))
}
}
func TestHandleCreateFile(t *testing.T) {
server, dataDir := setupTestServer(t)
reqBody := map[string]string{
"content": "Test content",
"name": "newfile.md",
}
reqBodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/files", nil)
req.Header.Set("Content-Type", "application/json")
req.Body = &bytesReader{bytes: reqBodyBytes}
w := httptest.NewRecorder()
server.handleCreateFile(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status 201, got %d", w.Code)
}
// Verify file was created
path := filepath.Join(dataDir, "newfile.md")
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("File was not created")
}
}
func TestHandleUpdateFile(t *testing.T) {
server, dataDir := setupTestServer(t)
// Create test file first
filename := "updatefile.md"
path := filepath.Join(dataDir, filename)
os.WriteFile(path, []byte("Original content"), 0644)
reqBody := map[string]string{
"content": "Updated content",
}
reqBodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/files/"+filename, nil)
req.Header.Set("Content-Type", "application/json")
req.Body = &bytesReader{bytes: reqBodyBytes}
w := httptest.NewRecorder()
server.handleUpdateFile(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify file content was updated
newContent, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
if string(newContent) != "Updated content" {
t.Error("File content was not updated correctly")
}
}
func TestHandleDeleteFile(t *testing.T) {
server, dataDir := setupTestServer(t)
// Create test file
filename := "deletefile.md"
path := filepath.Join(dataDir, filename)
os.WriteFile(path, []byte("Test content"), 0644)
req := httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
w := httptest.NewRecorder()
server.handleDeleteFile(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify file was deleted
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("File was not deleted")
}
}
func TestHandleStaticFiles(t *testing.T) {
server, _ := setupTestServer(t)
// Try to serve static file
req := httptest.NewRequest("GET", "/static/index.html", nil)
w := httptest.NewRecorder()
server.handleFrontend(w, req)
// Should return 301 redirect or 200 for index.html
if w.Code != http.StatusOK && w.Code != http.StatusMovedPermanently {
t.Errorf("Expected status 200 or 301, got %d", w.Code)
}
}