A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add markdown frontmatter parser and draft content endpoints

- Implement YAML frontmatter parser with Parse and Serialize functions
- Update GetFileContent to parse frontmatter from markdown files
- Add PUT endpoint for saving file drafts to SQLite
- Add database queries for draft content (save, get, delete)
- Modify GetFileContent to return draft content if available
- Support auto-save workflow for markdown editing

+265 -5
+1
backend/go.mod
··· 24 24 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 25 25 golang.org/x/oauth2 v0.27.0 // indirect 26 26 golang.org/x/sys v0.37.0 // indirect 27 + gopkg.in/yaml.v3 v3.0.1 // indirect 27 28 modernc.org/libc v1.67.6 // indirect 28 29 modernc.org/mathutil v1.7.1 // indirect 29 30 modernc.org/memory v1.11.0 // indirect
+1
backend/go.sum
··· 55 55 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 56 56 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 57 57 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 59 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 59 60 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 61 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+91 -1
backend/internal/api/handlers/repos.go
··· 11 11 "github.com/yourusername/markedit/internal/auth" 12 12 "github.com/yourusername/markedit/internal/connectors" 13 13 "github.com/yourusername/markedit/internal/database" 14 + "github.com/yourusername/markedit/internal/markdown" 14 15 ) 15 16 16 17 // RepoHandler handles repository-related endpoints ··· 174 175 // Get branch parameter 175 176 branch := r.URL.Query().Get("branch") 176 177 177 - // Get file content 178 + // Get file content from GitHub 178 179 content, err := connector.GetFileContent(r.Context(), owner, repo, path, branch) 179 180 if err != nil { 180 181 log.Printf("Failed to get file content: %v", err) ··· 182 183 return 183 184 } 184 185 186 + // Check if there's a draft version 187 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 188 + draft, err := h.db.GetDraftContent(userID, repoFullName, path) 189 + if err != nil { 190 + log.Printf("Warning: Failed to get draft content: %v", err) 191 + // Continue with original content if draft fetch fails 192 + } 193 + 194 + // If draft exists, use it instead of GitHub content 195 + if draft != nil { 196 + parsed, err := markdown.Parse(draft.Content) 197 + if err != nil { 198 + log.Printf("Warning: Failed to parse draft content: %v", err) 199 + } else { 200 + content.Content = parsed.Content 201 + content.Frontmatter = parsed.Frontmatter 202 + } 203 + } 204 + 185 205 w.Header().Set("Content-Type", "application/json") 186 206 json.NewEncoder(w).Encode(content) 187 207 } 208 + 209 + // UpdateFileRequest represents the request to update a file 210 + type UpdateFileRequest struct { 211 + Content string `json:"content"` 212 + Frontmatter map[string]interface{} `json:"frontmatter"` 213 + } 214 + 215 + // UpdateFileContent saves file content to draft storage 216 + func (h *RepoHandler) UpdateFileContent(w http.ResponseWriter, r *http.Request) { 217 + owner := chi.URLParam(r, "owner") 218 + repo := chi.URLParam(r, "repo") 219 + path := chi.URLParam(r, "*") 220 + 221 + if owner == "" || repo == "" || path == "" { 222 + http.Error(w, "Missing required parameters", http.StatusBadRequest) 223 + return 224 + } 225 + 226 + // Get user from session 227 + session, err := auth.GetSession(r) 228 + if err != nil { 229 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 230 + return 231 + } 232 + 233 + userID, ok := auth.GetUserID(session) 234 + if !ok { 235 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 236 + return 237 + } 238 + 239 + // Parse request body 240 + var req UpdateFileRequest 241 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 242 + http.Error(w, "Invalid request body", http.StatusBadRequest) 243 + return 244 + } 245 + 246 + // Serialize frontmatter and content back into a single markdown string 247 + fullContent, err := markdown.Serialize(req.Frontmatter, req.Content) 248 + if err != nil { 249 + log.Printf("Failed to serialize markdown: %v", err) 250 + http.Error(w, "Failed to serialize content", http.StatusInternalServerError) 251 + return 252 + } 253 + 254 + // Save to draft storage 255 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 256 + draft := &database.DraftContent{ 257 + UserID: userID, 258 + RepoFullName: repoFullName, 259 + FilePath: path, 260 + Content: fullContent, 261 + } 262 + 263 + if err := h.db.SaveDraftContent(draft); err != nil { 264 + log.Printf("Failed to save draft: %v", err) 265 + http.Error(w, "Failed to save draft", http.StatusInternalServerError) 266 + return 267 + } 268 + 269 + response := map[string]interface{}{ 270 + "success": true, 271 + "draft_saved": true, 272 + "saved_at": draft.LastSavedAt, 273 + } 274 + 275 + w.Header().Set("Content-Type", "application/json") 276 + json.NewEncoder(w).Encode(response) 277 + }
+1
backend/internal/api/router.go
··· 58 58 r.Get("/api/repos", repoHandler.ListRepositories) 59 59 r.Get("/api/repos/{owner}/{repo}/files", repoHandler.ListFiles) 60 60 r.Get("/api/repos/{owner}/{repo}/files/*", repoHandler.GetFileContent) 61 + r.Put("/api/repos/{owner}/{repo}/files/*", repoHandler.UpdateFileContent) 61 62 }) 62 63 63 64 return r
+12 -4
backend/internal/connectors/github.go
··· 7 7 "strings" 8 8 9 9 "github.com/google/go-github/v58/github" 10 + "github.com/yourusername/markedit/internal/markdown" 10 11 "golang.org/x/oauth2" 11 12 ) 12 13 ··· 185 186 return nil, fmt.Errorf("failed to decode content: %w", err) 186 187 } 187 188 189 + // Parse markdown with frontmatter 190 + parsed, err := markdown.Parse(content) 191 + if err != nil { 192 + return nil, fmt.Errorf("failed to parse markdown: %w", err) 193 + } 194 + 188 195 return &FileContent{ 189 - Content: content, 190 - Path: path, 191 - SHA: fileContent.GetSHA(), 192 - Branch: branch, 196 + Content: parsed.Content, 197 + Frontmatter: parsed.Frontmatter, 198 + Path: path, 199 + SHA: fileContent.GetSHA(), 200 + Branch: branch, 193 201 }, nil 194 202 }
+62
backend/internal/database/queries.go
··· 132 132 // Note: In production, these tokens should be decrypted after retrieval 133 133 return token, nil 134 134 } 135 + 136 + // SaveDraftContent saves or updates draft content for a file 137 + func (db *DB) SaveDraftContent(draft *DraftContent) error { 138 + query := ` 139 + INSERT INTO draft_content (user_id, repo_full_name, file_path, content, last_saved_at) 140 + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) 141 + ON CONFLICT(user_id, repo_full_name, file_path) DO UPDATE SET 142 + content = excluded.content, 143 + last_saved_at = CURRENT_TIMESTAMP 144 + RETURNING id, last_saved_at 145 + ` 146 + 147 + return db.QueryRow(query, 148 + draft.UserID, 149 + draft.RepoFullName, 150 + draft.FilePath, 151 + draft.Content, 152 + ).Scan(&draft.ID, &draft.LastSavedAt) 153 + } 154 + 155 + // GetDraftContent retrieves draft content for a file 156 + func (db *DB) GetDraftContent(userID int, repoFullName, filePath string) (*DraftContent, error) { 157 + draft := &DraftContent{} 158 + query := ` 159 + SELECT id, user_id, repo_full_name, file_path, content, last_saved_at 160 + FROM draft_content 161 + WHERE user_id = ? AND repo_full_name = ? AND file_path = ? 162 + ` 163 + 164 + err := db.QueryRow(query, userID, repoFullName, filePath).Scan( 165 + &draft.ID, 166 + &draft.UserID, 167 + &draft.RepoFullName, 168 + &draft.FilePath, 169 + &draft.Content, 170 + &draft.LastSavedAt, 171 + ) 172 + 173 + if err == sql.ErrNoRows { 174 + return nil, nil // No draft found, return nil without error 175 + } 176 + if err != nil { 177 + return nil, fmt.Errorf("failed to get draft: %w", err) 178 + } 179 + 180 + return draft, nil 181 + } 182 + 183 + // DeleteDraftContent deletes draft content for a file 184 + func (db *DB) DeleteDraftContent(userID int, repoFullName, filePath string) error { 185 + query := ` 186 + DELETE FROM draft_content 187 + WHERE user_id = ? AND repo_full_name = ? AND file_path = ? 188 + ` 189 + 190 + _, err := db.Exec(query, userID, repoFullName, filePath) 191 + if err != nil { 192 + return fmt.Errorf("failed to delete draft: %w", err) 193 + } 194 + 195 + return nil 196 + }
+97
backend/internal/markdown/parser.go
··· 1 + package markdown 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "gopkg.in/yaml.v3" 9 + ) 10 + 11 + // ParsedContent represents a markdown file with frontmatter 12 + type ParsedContent struct { 13 + Frontmatter map[string]interface{} 14 + Content string 15 + } 16 + 17 + // Parse extracts YAML frontmatter and markdown content from a file 18 + func Parse(input string) (*ParsedContent, error) { 19 + // Check if the file starts with frontmatter delimiter 20 + if !strings.HasPrefix(input, "---\n") && !strings.HasPrefix(input, "---\r\n") { 21 + // No frontmatter, return entire content as markdown 22 + return &ParsedContent{ 23 + Frontmatter: make(map[string]interface{}), 24 + Content: input, 25 + }, nil 26 + } 27 + 28 + // Find the end of frontmatter 29 + lines := strings.Split(input, "\n") 30 + endIdx := -1 31 + 32 + for i := 1; i < len(lines); i++ { 33 + trimmed := strings.TrimSpace(lines[i]) 34 + if trimmed == "---" { 35 + endIdx = i 36 + break 37 + } 38 + } 39 + 40 + // If we didn't find the closing delimiter, treat as no frontmatter 41 + if endIdx == -1 { 42 + return &ParsedContent{ 43 + Frontmatter: make(map[string]interface{}), 44 + Content: input, 45 + }, nil 46 + } 47 + 48 + // Extract frontmatter YAML (between the delimiters) 49 + frontmatterLines := lines[1:endIdx] 50 + frontmatterYAML := strings.Join(frontmatterLines, "\n") 51 + 52 + // Parse YAML 53 + var frontmatter map[string]interface{} 54 + if err := yaml.Unmarshal([]byte(frontmatterYAML), &frontmatter); err != nil { 55 + return nil, fmt.Errorf("failed to parse frontmatter YAML: %w", err) 56 + } 57 + 58 + // Extract content (everything after the closing delimiter) 59 + contentLines := lines[endIdx+1:] 60 + content := strings.Join(contentLines, "\n") 61 + // Trim leading newline if present 62 + content = strings.TrimPrefix(content, "\n") 63 + 64 + return &ParsedContent{ 65 + Frontmatter: frontmatter, 66 + Content: content, 67 + }, nil 68 + } 69 + 70 + // Serialize combines frontmatter and content back into a single string 71 + func Serialize(frontmatter map[string]interface{}, content string) (string, error) { 72 + // If no frontmatter, return just the content 73 + if len(frontmatter) == 0 { 74 + return content, nil 75 + } 76 + 77 + // Marshal frontmatter to YAML 78 + var buf bytes.Buffer 79 + encoder := yaml.NewEncoder(&buf) 80 + encoder.SetIndent(2) 81 + 82 + if err := encoder.Encode(frontmatter); err != nil { 83 + return "", fmt.Errorf("failed to marshal frontmatter: %w", err) 84 + } 85 + 86 + if err := encoder.Close(); err != nil { 87 + return "", fmt.Errorf("failed to close encoder: %w", err) 88 + } 89 + 90 + // Combine with delimiters 91 + yamlStr := buf.String() 92 + // Remove trailing newline from YAML output 93 + yamlStr = strings.TrimSuffix(yamlStr, "\n") 94 + 95 + result := fmt.Sprintf("---\n%s\n---\n%s", yamlStr, content) 96 + return result, nil 97 + }