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,195 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/markdown-editor/internal/storage"
"github.com/markdown-editor/pkg/logger"
)
type ErrorResponse struct {
Error string `json:"error"`
}
type Server struct {
storage *storage.Storage
host string
port int
}
func NewServer(dataDir, host string, port int) (*Server, error) {
return &Server{
storage: storage.NewStorage(dataDir),
host: host,
port: port,
}, nil
}
func (s *Server) Start() error {
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/files", s.handleFiles)
mux.HandleFunc("/api/files/", s.handleFiles)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Frontend SPA fallback
mux.HandleFunc("/", s.handleFrontend)
addr := fmt.Sprintf("%s:%d", s.host, s.port)
logger.Infof("Starting server on %s", addr)
return http.ListenAndServe(addr, mux)
}
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGetFiles(w, r)
case http.MethodPost:
s.handleCreateFile(w, r)
case http.MethodPut:
s.handleUpdateFile(w, r)
case http.MethodDelete:
s.handleDeleteFile(w, r)
default:
s.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handleGetFiles(w http.ResponseWriter, r *http.Request) {
// Check if URL includes a filename (e.g., /api/files/test.md)
filename := filepath.Base(r.URL.Path)
if filename != "" && r.URL.Path != "/api/files" {
// Get specific file
content, err := s.storage.GetFile(filename)
if err != nil {
s.sendError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "text/markdown")
w.Write([]byte(content))
return
}
// List all files
files, err := s.storage.ListFiles()
if err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte{'['})
for i, file := range files {
if i > 0 {
w.Write([]byte{','})
}
w.Write([]byte{'"'})
w.Write([]byte(file))
w.Write([]byte{'"'})
}
w.Write([]byte{']'})
}
func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(req.Name, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
if err := s.storage.SaveFile(req.Name, req.Content); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Created file: %s", req.Name)
w.WriteHeader(http.StatusCreated)
}
func (s *Server) handleUpdateFile(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(r.URL.Path)
if filename == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(filename, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := s.storage.SaveFile(filename, req.Content); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Updated file: %s", filename)
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(r.URL.Path)
if filename == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(filename, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
if err := s.storage.DeleteFile(filename); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Deleted file: %s", filename)
}
func (s *Server) handleFrontend(w http.ResponseWriter, r *http.Request) {
// Serve the index.html for SPA
http.ServeFile(w, r, "./static/index.html")
}
func (s *Server) sendError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}
func (s *Server) ServeStaticFiles(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
}

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)
}
}