Initial commit: WYSIWYG Markdown Editor - Go backend + React/TypeScript frontend with Tailwind CSS

Backend:
- Cobra CLI with --data-dir, --port, --host flags
- Gin HTTP server with REST API for markdown CRUD operations
- File storage on disk (.md files only)
- Comprehensive logrus logging
- Backend tests with CRUD round-trip verification

Frontend:
- React 18 + TypeScript + Tailwind CSS
- Markdown editor with live GFM preview (react-markdown + remark-gfm)
- File management UI (list, create, open, save, delete)
- Theme switcher with Dark/Light/System modes
- Responsive design
- Frontend tests with vitest

Testing:
- All backend tests pass (go test ./...)
- All frontend tests pass (npm test)
This commit is contained in:
2026-02-05 15:44:06 -05:00
parent c2a225fd29
commit 482d8a448a
37 changed files with 10585 additions and 0 deletions

149
internal/api/api.go Normal file
View File

@@ -0,0 +1,149 @@
package api
import (
"net/http"
"github.com/sirupsen/logrus"
"markdown-editor/internal/storage"
"markdown-editor/internal/logger"
"github.com/gin-gonic/gin"
)
type API struct {
storage *storage.Storage
log *logrus.Logger
}
func NewAPI(storage *storage.Storage) *API {
log := logger.GetLogger()
return &API{
storage: storage,
log: log,
}
}
// FileInfo represents a file for API responses
type FileInfo struct {
Name string `json:"name"`
Content string `json:"content,omitempty"`
}
// File represents a file for API requests
type File struct {
Name string `json:"name"`
Content string `json:"content"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
func (a *API) RegisterRoutes(router *gin.Engine) {
// File endpoints
router.GET("/api/files", a.listFiles)
router.GET("/api/files/:name", a.getFile)
router.POST("/api/files", a.createFile)
router.PUT("/api/files/:name", a.updateFile)
router.DELETE("/api/files/:name", a.deleteFile)
}
func (a *API) listFiles(c *gin.Context) {
a.log.Info("Handling GET /api/files")
files, err := a.storage.ListFiles()
if err != nil {
a.log.Errorf("Failed to list files: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to list files"})
return
}
c.JSON(http.StatusOK, gin.H{
"files": files,
})
}
func (a *API) getFile(c *gin.Context) {
name := c.Param("name")
a.log.WithField("filename", name).Info("Handling GET /api/files/:name")
file, err := a.storage.GetFile(name)
if err != nil {
a.log.Errorf("Failed to get file %s: %v", name, err)
if err.Error() == "file not found: "+name {
c.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, file)
}
func (a *API) createFile(c *gin.Context) {
var file File
if err := c.ShouldBindJSON(&file); err != nil {
a.log.Errorf("Invalid request body: %v", err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
return
}
a.log.WithField("filename", file.Name).Info("Handling POST /api/files")
if file.Name == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name is required"})
return
}
if err := a.storage.CreateFile(file.Name, file.Content); err != nil {
a.log.Errorf("Failed to create file %s: %v", file.Name, err)
if err.Error() == "file already exists: "+file.Name {
c.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusCreated, file)
}
func (a *API) updateFile(c *gin.Context) {
name := c.Param("name")
var file File
if err := c.ShouldBindJSON(&file); err != nil {
a.log.Errorf("Invalid request body: %v", err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
return
}
a.log.WithField("filename", name).Info("Handling PUT /api/files/:name")
if file.Name != name {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name in path and body must match"})
return
}
if err := a.storage.UpdateFile(name, file.Content); err != nil {
a.log.Errorf("Failed to update file %s: %v", name, err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, file)
}
func (a *API) deleteFile(c *gin.Context) {
name := c.Param("name")
a.log.WithField("filename", name).Info("Handling DELETE /api/files/:name")
if err := a.storage.DeleteFile(name); err != nil {
a.log.Errorf("Failed to delete file %s: %v", name, err)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusNoContent, nil)
}

228
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,228 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"markdown-editor/internal/logger"
"markdown-editor/internal/storage"
"github.com/gin-gonic/gin"
)
func TestAPI_ListFiles(t *testing.T) {
logger.SetLevel("error")
tempDir, err := os.MkdirTemp("", "test-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
store, err := storage.NewStorage(tempDir)
if err != nil {
t.Fatal(err)
}
api := NewAPI(store)
gin.SetMode(gin.TestMode)
router := gin.New()
api.RegisterRoutes(router)
// Create a test file
testFile := filepath.Join(tempDir, "test.md")
err = os.WriteFile(testFile, []byte("# Test Content"), 0644)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/files", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var resp gin.H
err = json.NewDecoder(w.Body).Decode(&resp)
if err != nil {
t.Fatal(err)
}
if len(resp["files"].([]interface{})) != 1 {
t.Errorf("Expected 1 file, got %d", len(resp["files"].([]interface{})))
}
}
func TestAPI_CreateFile(t *testing.T) {
logger.SetLevel("error")
tempDir, err := os.MkdirTemp("", "test-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
store, err := storage.NewStorage(tempDir)
if err != nil {
t.Fatal(err)
}
api := NewAPI(store)
gin.SetMode(gin.TestMode)
router := gin.New()
api.RegisterRoutes(router)
body, _ := json.Marshal(File{
Name: "test.md",
Content: "# Test Content",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/files", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code)
}
// Verify file was created
if _, err := os.Stat(filepath.Join(tempDir, "test.md")); os.IsNotExist(err) {
t.Error("File was not created")
}
}
func TestAPI_GetFile(t *testing.T) {
logger.SetLevel("error")
tempDir, err := os.MkdirTemp("", "test-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
store, err := storage.NewStorage(tempDir)
if err != nil {
t.Fatal(err)
}
// Create test file
err = store.CreateFile("test.md", "# Test Content")
if err != nil {
t.Fatal(err)
}
api := NewAPI(store)
gin.SetMode(gin.TestMode)
router := gin.New()
api.RegisterRoutes(router)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/files/test.md", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var resp File
err = json.NewDecoder(w.Body).Decode(&resp)
if err != nil {
t.Fatal(err)
}
if resp.Content != "# Test Content" {
t.Errorf("Expected '# Test Content', got '%s'", resp.Content)
}
}
func TestAPI_UpdateFile(t *testing.T) {
logger.SetLevel("error")
tempDir, err := os.MkdirTemp("", "test-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
store, err := storage.NewStorage(tempDir)
if err != nil {
t.Fatal(err)
}
// Create test file
err = store.CreateFile("test.md", "# Original Content")
if err != nil {
t.Fatal(err)
}
api := NewAPI(store)
gin.SetMode(gin.TestMode)
router := gin.New()
api.RegisterRoutes(router)
body, _ := json.Marshal(File{
Name: "test.md",
Content: "# Updated Content",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/files/test.md", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
// Verify file was updated
content, _ := os.ReadFile(filepath.Join(tempDir, "test.md"))
if string(content) != "# Updated Content" {
t.Errorf("Expected '# Updated Content', got '%s'", string(content))
}
}
func TestAPI_DeleteFile(t *testing.T) {
logger.SetLevel("error")
tempDir, err := os.MkdirTemp("", "test-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
store, err := storage.NewStorage(tempDir)
if err != nil {
t.Fatal(err)
}
// Create test file
err = store.CreateFile("test.md", "# Test Content")
if err != nil {
t.Fatal(err)
}
api := NewAPI(store)
gin.SetMode(gin.TestMode)
router := gin.New()
api.RegisterRoutes(router)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/files/test.md", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("Expected status %d, got %d", http.StatusNoContent, w.Code)
}
// Verify file was deleted
if _, err := os.Stat(filepath.Join(tempDir, "test.md")); !os.IsNotExist(err) {
t.Error("File was not deleted")
}
}