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:
2026-02-06 10:01:09 -05:00
parent 6b66cee21d
commit 773b9db4b2
44 changed files with 7692 additions and 0 deletions

172
backend/server/server.go Normal file
View 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)
}