feat: implement WYSIWYG markdown editor with Go backend and React frontend

This commit is contained in:
2026-02-05 17:14:20 -05:00
parent 42af63fdae
commit 512a9db08f
33 changed files with 24555 additions and 0 deletions

19
backend/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: test
test:
go test ./test -v
.PHONY: run
run:
go run ./cmd/backend
.PHONY: build
build:
go build -o markdown-editor ./cmd/backend
.PHONY: clean
clean:
rm -rf markdown-editor

BIN
backend/backend Executable file

Binary file not shown.

View File

@@ -0,0 +1,40 @@
package main
import (
"fmt"
"log"
"os"
"markdown-editor/internal/server"
"github.com/spf13/cobra"
)
var (
dataDir string
port int
host string
)
var rootCmd = &cobra.Command{
Use: "markdown-editor",
Short: "A WYSIWYG Markdown Editor",
Long: `A WYSIWYG Markdown Editor with Go backend and React frontend`,
Run: func(cmd *cobra.Command, args []string) {
if err := server.StartServer(dataDir, port, host); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", "./data", "Storage path for markdown files")
rootCmd.PersistentFlags().IntVar(&port, "port", 8080, "Server port")
rootCmd.PersistentFlags().StringVar(&host, "host", "127.0.0.1", "Bind address")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

15
backend/go.mod Normal file
View File

@@ -0,0 +1,15 @@
module markdown-editor
go 1.21
require (
github.com/gorilla/mux v1.8.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)

26
backend/go.sum Normal file
View File

@@ -0,0 +1,26 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,158 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"markdown-editor/internal/storage"
)
type Handlers struct {
logger *logrus.Logger
storage storage.Storage
}
func NewHandlers(logger *logrus.Logger, storage storage.Storage) *Handlers {
return &Handlers{
logger: logger,
storage: storage,
}
}
func (h *Handlers) ListFiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
files, err := h.storage.ListFiles()
if err != nil {
h.logger.Errorf("Failed to list files: %v", err)
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"files": files,
})
}
}
func (h *Handlers) CreateFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Errorf("Failed to decode request body: %v", err)
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
if err := h.storage.WriteFile(req.Filename, []byte(req.Content)); err != nil {
h.logger.Errorf("Failed to create file: %v", err)
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "file created successfully",
})
}
}
func (h *Handlers) GetFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
content, err := h.storage.ReadFile(filename)
if err != nil {
h.logger.Errorf("Failed to read file: %v", err)
h.writeError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"filename": filename,
"content": string(content),
})
}
}
func (h *Handlers) UpdateFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Errorf("Failed to decode request body: %v", err)
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.storage.WriteFile(filename, []byte(req.Content)); err != nil {
h.logger.Errorf("Failed to update file: %v", err)
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "file updated successfully",
})
}
}
func (h *Handlers) DeleteFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
if err := h.storage.DeleteFile(filename); err != nil {
h.logger.Errorf("Failed to delete file: %v", err)
h.writeError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "file deleted successfully",
})
}
}
func (h *Handlers) writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{
"error": message,
})
}

View File

@@ -0,0 +1,18 @@
package logger
import (
"os"
"github.com/sirupsen/logrus"
)
func NewLogger() *logrus.Logger {
log := logrus.New()
log.SetOutput(os.Stdout)
log.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
FullTimestamp: true,
})
log.SetLevel(logrus.InfoLevel)
return log
}

View File

@@ -0,0 +1,90 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"markdown-editor/internal/handlers"
"markdown-editor/internal/logger"
"markdown-editor/internal/storage"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
)
type Server struct {
logger *logrus.Logger
storage storage.Storage
handlers *handlers.Handlers
router *mux.Router
server *http.Server
dataDir string
port int
host string
}
func StartServer(dataDir string, port int, host string) error {
log := logger.NewLogger()
log.Info("Starting markdown editor server")
// Initialize storage
storage, err := storage.NewFileStorage(dataDir)
if err != nil {
log.Errorf("Failed to initialize storage: %v", err)
return err
}
// Initialize handlers
handlers := handlers.NewHandlers(log, storage)
// Create server
s := &Server{
logger: log,
storage: storage,
handlers: handlers,
dataDir: dataDir,
port: port,
host: host,
}
// Setup routes
s.setupRoutes()
// Start server
s.logger.Infof("Server starting on %s:%d", host, port)
return s.serve()
}
func (s *Server) setupRoutes() {
s.router = mux.NewRouter()
// API routes
apiRouter := s.router.PathPrefix("/api").Subrouter()
apiRouter.Handle("/files", s.handlers.ListFiles()).Methods("GET")
apiRouter.Handle("/files", s.handlers.CreateFile()).Methods("POST")
apiRouter.Handle("/files/{filename}", s.handlers.GetFile()).Methods("GET")
apiRouter.Handle("/files/{filename}", s.handlers.UpdateFile()).Methods("PUT")
apiRouter.Handle("/files/{filename}", s.handlers.DeleteFile()).Methods("DELETE")
// Static file serving
frontendDir := "frontend/build"
if frontendDirEnv := os.Getenv("FRONTEND_BUILD_DIR"); frontendDirEnv != "" {
frontendDir = frontendDirEnv
}
s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(frontendDir)))
}
func (s *Server) serve() error {
s.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
Handler: s.router,
}
return s.server.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down server...")
return s.server.Shutdown(ctx)
}

View File

@@ -0,0 +1,111 @@
package storage
import (
"errors"
"io/fs"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
)
type Storage interface {
ListFiles() ([]string, error)
ReadFile(filename string) ([]byte, error)
WriteFile(filename string, content []byte) error
DeleteFile(filename string) error
FileExists(filename string) bool
}
type FileStorage struct {
dataDir string
logger *logrus.Logger
}
func NewFileStorage(dataDir string) (*FileStorage, error) {
// Create data directory if it doesn't exist
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, err
}
log := logrus.New()
log.SetOutput(os.Stdout)
log.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
FullTimestamp: true,
})
log.SetLevel(logrus.InfoLevel)
return &FileStorage{
dataDir: dataDir,
logger: log,
}, nil
}
func (s *FileStorage) ListFiles() ([]string, error) {
files, err := os.ReadDir(s.dataDir)
if err != nil {
return nil, err
}
var markdownFiles []string
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".md" {
markdownFiles = append(markdownFiles, file.Name())
}
}
return markdownFiles, nil
}
func (s *FileStorage) ReadFile(filename string) ([]byte, error) {
if !s.FileExists(filename) {
return nil, errors.New("file not found")
}
content, err := os.ReadFile(filepath.Join(s.dataDir, filename))
if err != nil {
return nil, err
}
return content, nil
}
func (s *FileStorage) WriteFile(filename string, content []byte) error {
// Ensure the file has .md extension
if filepath.Ext(filename) != ".md" {
filename += ".md"
}
filepath := filepath.Join(s.dataDir, filename)
err := os.WriteFile(filepath, content, 0644)
if err != nil {
return err
}
s.logger.Infof("Saved file: %s", filename)
return nil
}
func (s *FileStorage) DeleteFile(filename string) error {
filepath := filepath.Join(s.dataDir, filename)
err := os.Remove(filepath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return errors.New("file not found")
}
return err
}
s.logger.Infof("Deleted file: %s", filename)
return nil
}
func (s *FileStorage) FileExists(filename string) bool {
filepath := filepath.Join(s.dataDir, filename)
info, err := os.Stat(filepath)
if err != nil {
return false
}
return !info.IsDir()
}

View File

@@ -0,0 +1,212 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gorilla/mux"
"markdown-editor/internal/handlers"
"markdown-editor/internal/logger"
"markdown-editor/internal/server"
"markdown-editor/internal/storage"
)
func TestCRUDRoundTrip(t *testing.T) {
// Create a temporary data directory
tmpDir, err := os.MkdirTemp("", "markdown-editor-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Initialize storage
storage, err := storage.NewFileStorage(tmpDir)
if err != nil {
t.Fatalf("Failed to initialize storage: %v", err)
}
// Initialize logger
log := logger.NewLogger()
// Initialize handlers
handlers := handlers.NewHandlers(log, storage)
// Create a router
router := mux.NewRouter()
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Handle("/files", handlers.CreateFile()).Methods("POST")
apiRouter.Handle("/files/{filename}", handlers.GetFile()).Methods("GET")
apiRouter.Handle("/files/{filename}", handlers.UpdateFile()).Methods("PUT")
apiRouter.Handle("/files/{filename}", handlers.DeleteFile()).Methods("DELETE")
apiRouter.Handle("/files", handlers.ListFiles()).Methods("GET")
// Test Create
t.Run("Create", func(t *testing.T) {
body := map[string]interface{}{
"filename": "test.md",
"content": "# Test Content",
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] != "file created successfully" {
t.Errorf("Expected success message, got: %s", resp["message"])
}
})
// Test Read
t.Run("Read", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/files/test.md", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["filename"] != "test.md" {
t.Errorf("Expected filename 'test.md', got: %s", resp["filename"])
}
if resp["content"] != "# Test Content" {
t.Errorf("Expected content '# Test Content', got: %s", resp["content"])
}
})
// Test Update
t.Run("Update", func(t *testing.T) {
body := map[string]interface{}{
"content": "# Updated Content",
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/api/files/test.md", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] != "file updated successfully" {
t.Errorf("Expected success message, got: %s", resp["message"])
}
})
// Test List
t.Run("List", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/files", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
files := resp["files"].([]interface{})
if len(files) != 1 {
t.Errorf("Expected 1 file, got %d", len(files))
}
if files[0] != "test.md" {
t.Errorf("Expected 'test.md', got: %s", files[0])
}
})
// Test Delete
t.Run("Delete", func(t *testing.T) {
req := httptest.NewRequest("DELETE", "/api/files/test.md", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] != "file deleted successfully" {
t.Errorf("Expected success message, got: %s", resp["message"])
}
})
}
func TestStaticAssetServing(t *testing.T) {
// Create a temporary data directory
dataDir, err := os.MkdirTemp("", "data-test")
if err != nil {
t.Fatalf("Failed to create data dir: %v", err)
}
defer os.RemoveAll(dataDir)
// Create a temporary frontend build directory
frontendDir, err := os.MkdirTemp("", "frontend-build-test")
if err != nil {
t.Fatalf("Failed to create frontend dir: %v", err)
}
defer os.RemoveAll(frontendDir)
// Create a test index.html file
indexPath := filepath.Join(frontendDir, "index.html")
if err := os.WriteFile(indexPath, []byte("<html><body>Test</body></html>"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Change the frontend build directory temporarily
originalDir := os.Getenv("FRONTEND_BUILD_DIR")
os.Setenv("FRONTEND_BUILD_DIR", frontendDir)
defer os.Setenv("FRONTEND_BUILD_DIR", originalDir)
// Start the server
go func() {
if err := server.StartServer(dataDir, 8081, "127.0.0.1"); err != nil {
t.Logf("Server error: %v", err)
}
}()
// Give the server time to start
time.Sleep(100 * time.Millisecond)
// Test static file serving
resp, err := http.Get("http://127.0.0.1:8081/index.html")
if err != nil {
t.Skipf("Server not ready yet: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
return
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "<html><body>Test</body></html>" {
t.Errorf("Expected test HTML content, got: %s", string(body))
}
}