Collaborative Markdown editing, powered by Git. Edit together in real time, with every change backed by a full Git history.
MDArena gives teams a shared workspace for Markdown files where multiple people can edit simultaneously without conflicts. Under the hood, each project is a bare Git repository. You can edit through the web interface or clone with git and push changes from your terminal. Both workflows stay in sync.
- Features
- Roadmap
- Architecture
- Tech Stack
- Getting Started
- Development
- Testing
- Project Structure
- How It Works
- Deployment
Real-time collaborative editing -- Multiple users edit the same file simultaneously with live cursors and zero conflicts, powered by Y.js CRDTs.
Git-backed versioning -- Every save creates a Git commit. Browse full file history, view any past revision, and see who changed what.
Clone over HTTPS -- Each project is a standard Git repository. Clone it, edit locally with your favorite tools, and push changes back.
Split-pane editor -- Write Markdown on the left, see the rendered preview on the right. The editor uses CodeMirror 6 with syntax highlighting and collaborative cursor indicators.
Project-based organization -- Group files into projects. Invite team members as editors or owners. Each project gets its own Git repository and clone URL.
Auto-save -- Changes are automatically committed to Git after 60 seconds of inactivity. No work is lost even if you forget to save.
Mobile-friendly -- Responsive layout with a collapsible sidebar, stacking forms, and scrollable tables. The editor hides the preview pane on small screens to maximize editing space.
Planned next features:
- Delete projects -- Allow project owners to delete a project and its bare Git repository (with a safety confirmation).
- Inline highlights + comments -- Highlight text ranges and start comment threads anchored to the selected content.
- File-level comments -- A general comment thread under each file (not tied to a specific highlight).
- @mentions in comments -- Mention project members with autocomplete and notifications.
- Full-text search -- Search across files in a project by filename and content.
Browser (Vue 3 + CodeMirror + Y.js)
|
|-- Inertia.js -------- Rails Controllers ------- PostgreSQL
| (users, projects, memberships)
|
|-- Action Cable ------- DocumentChannel --------- Rails.cache
| (Y.js CRDT state)
| |
| FlushYdocToGitJob
| |
|-- Git HTTP ---- Nginx ---- git-http-backend ---- Bare Git Repos
(clone/push) (tmp/repos/)
The application uses a hybrid storage model. PostgreSQL stores user accounts, projects, and memberships. Git repositories store all file content and history. The Rails cache holds active Y.js CRDT state during collaborative editing sessions, which gets periodically flushed to Git as commits.
| Layer | Technology |
|---|---|
| Language | Ruby 3.4, JavaScript (ES modules) |
| Backend | Rails 8.1 |
| Frontend | Vue 3.5, Vite 6, Inertia.js |
| UI | Nuxt UI v4, Tailwind CSS 4 |
| Database | PostgreSQL 18 |
| Editor | CodeMirror 6 |
| Collaboration | Y.js CRDTs, y-codemirror.next |
| Real-time | Action Cable (Solid Cable adapter) |
| Git | Rugged (libgit2), Nginx + git-http-backend |
| Background jobs | GoodJob |
| Caching | Solid Cache (production), in-memory (development) |
| Auth | Devise |
| Deployment | Kamal, Docker |
- mise (manages Ruby, Node.js, and Yarn versions)
- Docker (for PostgreSQL and the Git HTTP server)
- libgit2 development headers (for the
ruggedgem)
On macOS:
brew install mise libgit2Clone the repository and run the full setup:
git clone <repo-url> md-arena
cd md-arena
# Install tool versions (Ruby 3.4.7, Node 25.2.1, Yarn 4.12.0)
mise install
# Start Docker services, install dependencies, prepare databases, and boot the app
mise run devThis single command starts everything: PostgreSQL, the Git HTTP server (Nginx), the Rails server, the Vite dev server, and background jobs (GoodJob).
Once running, visit:
- App: http://localhost:3000
- Rails server: http://localhost:3001 (direct, bypasses Nginx)
- Vite dev server: http://localhost:3036
If you prefer to set things up step by step:
# Start Docker services (PostgreSQL + Git Nginx)
mise run docker:start
# Install dependencies
mise run deps
# Prepare the database
mise run db:prepare
# Start the development server
bin/devThe development environment runs two processes via Foreman (bin/dev):
| Process | Port | Purpose |
|---|---|---|
| Rails server | 3001 | Backend API and Inertia page rendering |
| Vite dev server | 3036 | Frontend asset compilation with HMR |
By default, jobs run in-process in development (GOOD_JOB_EXECUTION_MODE=async). If you want an external worker, set GOOD_JOB_EXECUTION_MODE=external and run bundle exec good_job start.
Two Docker containers run alongside:
| Container | Port | Purpose |
|---|---|---|
| PostgreSQL 18 | 5432 | Primary, cable, and cache databases |
| Nginx + git-http-backend | 3000 | Git clone/push/pull over HTTPS |
The app uses three PostgreSQL databases:
- Primary (
md_arena_development) -- Users, projects, memberships - Cable (
md_arena_development_cable) -- Solid Cable message queue for Action Cable - Cache (
md_arena_production_cache) -- Solid Cache entries (production only)
# Start/stop Docker services
mise run docker:start
mise run docker:stop
# Install all dependencies
mise run deps
# Run all linters
mise run lint
# Run the test suite
mise run test
# Rails console
bin/rails console
# Database console
bin/rails dbconsoleThe project enforces consistent code style with three tools:
# Ruby linting (auto-fix with -A)
bundle exec rubocop
bundle exec rubocop -A
# JavaScript linting
yarn lint
# JavaScript formatting
yarn format:check
yarn formatRuboCop inherits from rubocop-rails-omakase. JavaScript uses oxlint for linting and oxfmt for formatting.
Tests use Minitest with parallel execution:
# Run unit and integration tests
bin/rails test
# Run system tests (requires Chrome)
bin/rails test:system
# Run everything
mise run testTest coverage includes:
- Models -- User, Project, ProjectMembership validations and associations
- Controllers -- All routes including file CRUD, history, memberships, auth, settings, and Git authorization
- Channels -- Action Cable connection authentication and DocumentChannel message handling
- Jobs -- FlushYdocToGitJob and CleanupStaleYdocsJob
- Services -- GitService operations (init, read, write, delete, history, locking)
GitHub Actions runs four jobs on every pull request and push to main:
- scan_ruby -- Brakeman (static security analysis) and bundler-audit (dependency vulnerabilities)
- lint -- RuboCop with caching
- test -- Unit and integration tests against PostgreSQL
- system-test -- Browser tests with screenshot capture on failure
app/
channels/
document_channel.rb # Real-time collaborative editing via Y.js
controllers/
api/git/authorize_controller.rb # Git HTTP authentication
files_controller.rb # File CRUD (reads/writes Git)
file_history_controller.rb # Git commit log browsing
projects_controller.rb # Project management
project_memberships_controller.rb # Team member management
settings/profile_controller.rb # User profile settings
users/sessions_controller.rb # Devise sign in (Inertia)
users/registrations_controller.rb # Devise sign up (Inertia)
javascript/
components/
MarkdownPreview.vue # Renders Markdown to HTML via markdown-it
layouts/
AppLayout.vue # Main layout with sidebar navigation
EditorLayout.vue # Minimal full-screen layout for the editor
pages/
Auth/SignIn.vue # Login page
Auth/SignUp.vue # Registration page
Projects/Index.vue # Project listing with search
Projects/Show.vue # File listing and clone URL
Projects/Settings.vue # Member management
Settings/Profile.vue # User profile form
Files/Show.vue # Read-only file view
Files/Edit.vue # Collaborative editor (CodeMirror + Y.js)
Files/History.vue # Git commit history
Files/HistoryShow.vue # File at a specific revision
css/
application.css # Tailwind CSS + Nuxt UI imports
entrypoints/
application.js # Inertia.js app initialization
jobs/
flush_ydoc_to_git_job.rb # Commits Y.js state to Git
cleanup_stale_ydocs_job.rb # Flushes inactive documents
models/
user.rb # Devise user with project associations
project.rb # Project with slug, UUID, and Git repo
project_membership.rb # Join table with owner/editor roles
services/
git_service.rb # All Git operations via Rugged (libgit2)
config/
deploy.yml # Kamal deployment configuration
nginx/ # Nginx configs for Git HTTP and proxying
docker/ # Dev Docker setup for Git Nginx
When a user opens a file in the editor:
- The browser connects to the
DocumentChannelvia Action Cable (WebSocket). - The channel loads the Y.js CRDT state from the Rails cache, or initializes it from the Git repository if no cached state exists.
- The initial state is sent to the client, where Y.js populates the CodeMirror editor.
- As the user types, Y.js generates CRDT updates that are broadcast to all connected clients through the channel.
- Remote updates are applied locally by Y.js, which merges them automatically without conflicts.
- Each user's cursor position and selection are shared via the Y.js awareness protocol, rendered as colored indicators in other users' editors.
- After 60 seconds of inactivity (or on manual save), a background job extracts the text from the Y.js document and commits it to the Git repository.
The Y.js CRDT layer means two users can type in the same paragraph at the same time and their edits will merge correctly. No manual conflict resolution is needed.
Each project maps to a bare Git repository stored at {repos_root}/{project.uuid}.git. The UUID (not the slug) is used so that renaming a project does not require moving the repository on disk.
All file operations go through GitService, which uses the Rugged gem (libgit2 bindings) to manipulate blobs, trees, and commits directly on the bare repository. There is no working directory.
File-level locking via flock prevents race conditions when multiple processes write to the same repository simultaneously.
Users can clone and push to their projects over HTTPS:
git clone http://localhost:3000/git/my-project.gitWhen prompted, use your MDArena email as the username and your account password.
The Git HTTP flow works as follows:
- The client sends a Git request with Basic Auth credentials.
- Nginx intercepts the request and sends a subrequest to
/api/git/authorize. - Rails validates the credentials and checks project membership.
- On success, Rails returns the repository UUID in an
X-Repo-UUIDheader. - Nginx proxies the request to
git-http-backend, which serves the bare repository.
Push operations require the user to have an owner or editor role on the project.
The DocumentChannel checks the Git HEAD SHA every 10 seconds. If it detects that HEAD has changed (from a git push or another source), it invalidates the cached Y.js state and notifies all connected clients to reload the file. This keeps the web editor in sync with changes made outside the browser.
Two background jobs keep the system consistent:
-
FlushYdocToGitJob -- Runs 30 seconds after the last edit (debounced) or 5 seconds after the last user disconnects. Extracts text from the cached Y.js document, compares it with the Git file content, and creates a commit if there are changes.
-
CleanupStaleYdocsJob -- Runs every 15 minutes via cron. Finds Y.js documents in the cache that have no active WebSocket connections, flushes them to Git, and removes them from the cache.
The app deploys with Kamal using Docker. The production image bundles Rails, Puma, Nginx, fcgiwrap, and git-http-backend into a single container.
Two containers run on the server:
- web -- Nginx (port 80) proxies to Puma (port 3000) for the Rails app, serves static assets, handles WebSocket upgrades for Action Cable, and routes Git HTTP requests through fcgiwrap to git-http-backend.
- job -- A dedicated GoodJob worker for background job processing.
Both containers share a /var/repos volume where bare Git repositories are stored.
| Variable | Purpose |
|---|---|
RAILS_MASTER_KEY |
Decrypts Rails credentials |
DATABASE_URL |
Primary PostgreSQL connection |
CABLE_DATABASE_URL |
Solid Cable database connection |
CACHE_DATABASE_URL |
Solid Cache database connection |
QUEUE_DATABASE_URL |
GoodJob queue database connection |
GIT_REPOS_ROOT |
Path to bare Git repositories (default: /var/repos) |
kamal setup # First-time setup (provisions server, starts accessories)
kamal deploy # Deploy a new version
kamal console # Open a Rails console on the server
kamal logs # Tail production logs