markdownr
A local web server for browsing and reading markdown files with a clean, book-inspired interface.
Point it at any directory and get a navigable website with rendered markdown, syntax-highlighted code, YAML frontmatter display, wiki-link resolution, and auto-generated tables of contents.
Rendering docs/examples/overview.md
This was written primarily by Claude Code.
Install
gem install markdownr
Development
git clone https://github.com/brianmd/markdown-server.git
cd markdown-server
bundle install
ruby bin/markdownr [directory]
Usage
markdownr [options] [directory]
Serves the current directory if none is specified.
Options
| Flag | Description | Default |
|---|---|---|
-p, --port PORT
|
Port to listen on | 4567 |
-b, --bind ADDRESS
|
Address to bind to | 127.0.0.1 |
-T, --threads N
|
Number of server threads | 5 |
-t, --title TITLE
|
Custom page title | Directory name, titleized |
-i, --index-file FILENAME
|
Render this file instead of the directory listing when present (e.g. index.md, moc.md) |
Off |
--behind-proxy |
Trust X-Forwarded-For for client IP (use when behind a reverse proxy such as Caddy or nginx) |
Off |
--allow-robots |
Allow search engine crawling | Disallowed |
--no-link-tooltips |
Disable preview tooltips on local markdown links | Enabled |
--standard-newlines |
Treat single newlines as spaces (standard markdown); default is Obsidian-style where single newlines become line breaks | Hard-wrap on |
--verbose |
Print each request (time, IP, method, path) to stdout | Off |
--plugin NAME |
Enable a plugin by name (can be specified multiple times) | None |
--plugin-dir DIR |
Additional directory containing plugins (can be specified multiple times) | None |
-v, --version
|
Show version and exit |
Examples
# Serve the current directory
markdownr
# Serve a specific directory on port 3000
markdownr -p 3000 ~/notes
# Run multiple servers at once (uses Procfile.markdownr)
overmind start -f Procfile.markdownr # start all
overmind restart lofts # restart one
overmind restart # restart all
overmind quit # stop all
# Serve with a custom title
markdownr -t "Field Notes" ~/research
# Enable the bible citations plugin
markdownr --plugin bible_citations ~/bible-notes
# Load plugins from an external directory
markdownr --plugin-dir ~/my-markdownr-plugins ~/notesExample .markdownr.yml
A minimal config enabling the bible citations plugin:
plugins:
bible_citations:
enabled: trueA fuller config with all available options:
plugins:
bible_citations:
enabled: true
version: "CEB" # Bible translation (default: CEB)
link_target: scrip # "scrip" or "biblegateway"
dictionary_url: https://example.com/dictionary.json
# Load additional plugins from external directories
plugin_dirs:
- /path/to/custom/plugins
- ~/my-markdownr-plugins
# Popup behavior for link previews
popups:
local_md: true # popups for internal .md links
local_html: false # popups for internal .html links
external: true # popups for external links
external_domains: [] # limit external popups to specific domainsFeatures
Markdown rendering
- GitHub Flavored Markdown -- tables, task lists, strikethrough, autolinks, and more (via Kramdown GFM)
- Syntax highlighting for fenced code blocks and standalone source files (via Rouge)
- YAML frontmatter parsed and displayed in a collapsible metadata table
-
Wiki links --
[[page-name]]resolves to matching.mdfiles anywhere in the directory tree
Table of contents
- Auto-generated sticky sidebar for documents with multiple headings (on wide screens)
- Scroll spy highlights the current heading as you read
- Swipe-to-reveal TOC drawer on touch devices -- swipe left to open, swipe right to dismiss
- Floating TOC button on narrow screens for mouse users -- click to open the sliding drawer
- Tapping a heading in the drawer navigates there and closes the panel
Search
- Full-text search across file contents within any directory subtree
- Searching from a markdown file shows only matches within that file
- Multi-word queries require all words to match (AND logic, any order)
- Each search term can be a regex (e.g.,
\d{4}ore.*him) - Results show matching lines with context, highlighted matches, and line numbers
- Clickable results jump directly to the matching line in the document
- Long lines are truncated with the match kept visible
- Search box available on every page (directories search within; files search their parent directory)
Navigation
- Directory browsing with file sizes, modification dates, and sortable columns (name, modified, created)
- Sort persistence -- your chosen sort order is remembered across directories via localStorage
- Breadcrumb navigation on every page, auto-hides on scroll and reappears on scroll-up or tap
- Scroll position memory -- reopening a document returns you to the last heading you were reading
File handling
- JSON files rendered as syntax-highlighted YAML for readability
- PDF served in an inline viewer
- EPUB served as a download
- Source files (
.py,.rb,.js,.sh,.yaml,.html, etc.) displayed with syntax highlighting - Other text files shown as plain text; binary files served as downloads
Link previews
- Hover preview -- hovering a local markdown link shows a popup with the linked document's content
- Click popup -- clicking a local markdown link shows the same popup; click again or follow to navigate
- Popup navigation -- links inside a popup load that document into the same popup, with a back button (←) to return to the previous document; browse several documents without leaving the page
- Popups auto-close on mouse leave; disabled with
--no-link-tooltips
Directory index files
- Set
--index-file index.md(or any filename) to render that file instead of the directory listing when it is present in a directory - The directory listing is still accessible via the breadcrumb or direct URL
- Embed the directory listing inline inside any markdown file using these tokens, each on its own line:
-
{{directory}}-- always renders the listing at that position -
{{admin-directory}}-- renders the listing only for admins; renders nothing for other visitors
-
Admin access
Place a .setup.yml file in the root of the served directory to enable admin features:
admin:
user: myusername # login form username
pw: mypassword # login form password
ip: 192.168.1.10 # optional: automatically grant admin if request comes from this IPThe ip field accepts a single address, a list of addresses, or CIDR notation:
admin:
ip:
- 192.168.86.0/24 # entire subnet
- 10.0.0.5 # specific addressAdmin access is required to save or delete CSV rows. Non-admin users can browse and view data but will be prompted to log in when attempting edits.
Admin routes:
-
GET /admin/login-- login form (username + password) -
GET /admin/logout-- clears the session -
GET /setup-info-- shows your IP, served path (admins only), and any non-admin config keys from.setup.yml
Any non-admin keys in .setup.yml are public and displayed on /setup-info. The admin block is never exposed.
Sessions are signed with a random secret (re-generated on each server start). For sessions that survive restarts, set the MARKDOWNR_SESSION_SECRET environment variable to a long random string before starting the server.
When running behind a reverse proxy, pass --behind-proxy so the real client IP is read from X-Forwarded-For instead of the direct connection address.
Configuration
Place a .markdownr.yml file in the root of your served directory (see Example .markdownr.yml above for the full format).
Plugin settings can also be controlled via environment variables:
MARKDOWNR_PLUGIN_BIBLE_CITATIONS_ENABLED=true markdownr ~/notesPriority order (highest wins): CLI flags > environment variables > .markdownr.yml > defaults.
CSV browser
- Schema-driven database browser -- define tables with JSON Schema properties in a YAML config and browse CSV data in a popup-based UI
- Foreign key navigation -- columns can reference other tables; clicking a foreign key cell opens the referenced table filtered to that record
- Related tables -- tables automatically show links to other tables that reference them (e.g., a Students table shows an Enrollments button on each row)
- Per-column filtering -- global regex search plus per-column filter inputs that combine with AND logic
- View/edit modes -- view mode for navigating between tables; edit mode for inline cell editing with server-side JSON Schema validation
- Table colors -- each table can have a color used for title bars, FK cell tints, and related-table buttons
See CSV Browser documentation for setup instructions, configuration reference, and a walkthrough of all features. A working example is in the school fixture.
Quick start -- add to .markdownr.yml:
csv_databases:
- data/school.yaml # path relative to served directoryBible citations (plugin)
-
Bare verse citations are auto-linked -- e.g.
John 3:16,Rom. 12:1,1 Cor 5:7 - Links are styled bold purple to distinguish them from regular links
- Citations inside code spans, fenced code blocks, or existing markdown links are left untouched
- Supports a wide range of book abbreviations (full names, standard short forms, dotted forms) for all OT, NT, and deuterocanonical books present in the CEB
-
Opt-in -- enable with
--plugin bible_citationsor via.markdownr.yml(see Configuration) - Configurable link target (
scriporbiblegateway), Bible version (default: CEB), and Strong's dictionary URL
Tables
- Wide-mode expand -- each table has a floating expand button (⤢) that widens the page to full viewport width, left-aligning the content for maximum reading area
- Tables scroll horizontally when they overflow on small screens
- Swiping on a table does not trigger the TOC drawer, so horizontal table scrolling works without interference
Responsive design
- Clean, book-inspired interface that adapts from desktop to mobile
- TOC transitions from a fixed sidebar to a swipe drawer on narrow screens
- Metadata tables reflow to stacked layout on mobile
Supported File Types
| Extension | Rendering |
|---|---|
.md |
Rendered markdown with TOC |
.json |
Syntax-highlighted YAML |
.pdf |
Inline PDF viewer |
.epub |
Download |
.py, .rb, .sh, .js, .yaml, .html
|
Syntax-highlighted source |
| Other text | Plain text display |
| Binary | Served as download |
Architecture
Request routing
flowchart TD
req[HTTP Request] --> route{Route}
route -->|GET /| redir[redirect → /browse/]
route -->|GET /robots.txt| robots[robots.txt response]
route -->|GET /version| ver[version + active plugins → JSON]
route -->|GET /download/*| dl[send_file as attachment]
route -->|GET /fetch| fetch[plugin dispatch → JSON]
route -->|GET /preview/*| prev[render_markdown → JSON]
route -->|GET /search/*| srch[compile_regexes → search.erb]
route -->|GET /browse/*| sp{safe_path\ncheck}
sp -->|traversal / excluded| err[403 / 404]
sp -->|directory| rd[render_directory\n→ directory.erb]
sp -->|file| ext{Extension?}
ext -->|.md| md[parse_frontmatter\nrender_markdown\nextract_toc\n→ markdown.erb]
ext -->|.json| json[JSON → YAML\nsyntax_highlight\n→ raw.erb]
ext -->|.pdf| pdf[send inline]
ext -->|.epub| epub[redirect /download/]
ext -->|source files| src[syntax_highlight\n→ raw.erb]
ext -->|binary| bin[send as download]
Markdown render pipeline
The steps inside render_markdown must run in this order:
flowchart TD
A[Raw markdown text] --> P
P["0 · Plugin transforms\nEach enabled plugin's transform_markdown\nruns in sequence (e.g. Bible citation auto-linking)"]
P --> B
B["1 · Resolve wiki links\n\[\[page\]\] and \[\[page|label\]\] → inline HTML\nMust run before Kramdown so the pipe character\nis not consumed as a GFM table delimiter"]
B --> C
C["2 · Kramdown GFM\nConverts markdown → HTML\nGenerates heading IDs and numbers all\nfootnote references sequentially (1, 2, 3…)"]
C --> D
D["3 · Restore footnote labels\nKramdown replaces \[^name\] with a number;\ntwo post-processing regexes restore the\noriginal label in both the inline superscript\nand the footnote list at the bottom"]
D --> E[Final HTML]
Testing
# Run unit and integration tests (no browser required)
bundle exec rspec
# Run all tests including browser tests (requires Chrome)
BROWSER_TESTS=1 bundle exec rspec
# Lint (style cops disabled, Lint/Security enabled)
bundle exec rubocop| File | Tests | Coverage |
|---|---|---|
spec/helpers_spec.rb |
38 |
parse_frontmatter, format_size/date, breadcrumbs, compile_regexes, extract_toc, render_markdown (wiki links, footnote label restoration, tables) |
spec/routes_spec.rb |
42 | All HTTP routes — redirects, directory/file rendering, frontmatter, TOC, search, downloads, 404/403, path traversal |
spec/layout_spec.rb |
27 | HTML/CSS/JS structure for the table wide-mode feature: right-sidebar div, expand button CSS, body.wide-mode rules, :has(:empty) logic, all JS identifiers |
spec/rendering_helpers_spec.rb |
20 | Markdown rendering helpers, wiki link resolution, frontmatter rendering |
spec/admin_spec.rb |
25 | Admin auth (IP, basic auth, session login), setup-info route, behind-proxy IP detection |
spec/plugin_spec.rb |
9 | Plugin module interface, PluginRegistry (registration, enable/disable, CLI overrides, config resolution) |
spec/plugins/bible_citations_plugin_spec.rb |
11 | BibleCitations plugin adapter (transform_markdown, transform_html, custom version) |
spec/bible_citations_spec.rb |
46 | Bible citation regex matching, abbreviation disambiguation, verse ranges, chapter ranges |
spec/bible_citations_html_spec.rb |
14 | HTML-aware citation linking, skip tags/scripts/anchors |
spec/biblegateway_url_spec.rb |
6 | BibleGateway URL construction, custom version keyword |
spec/html_injection_spec.rb |
11 | HTML file serving with plugin injection, popup assets |
spec/file_types_spec.rb |
12 | File type rendering (JSON→YAML, source highlighting, binary downloads) |
spec/csv_browser_spec.rb |
37 | CSV browser API: table data, views, references, reverse references, row editing, validation, render API |
spec/csv_browser_browser_spec.rb |
20 | Real Chrome: CSV popup view/edit modes, per-column filters, FK navigation, reverse reference links, display-value filtering |
spec/browser_spec.rb |
26 | Real Chrome: table DOM wrapping, expand button visibility, wide-mode toggle, two-table interactions, right-sidebar :empty CSS |
spec/scrip_popup_spec.rb |
9 | Scripture page popup rendering, CSS inlining, content detection (requires local scrip server) |
Browser tests use headless Chrome via Capybara and selenium-webdriver. On macOS, the spec automatically locates the version-matched chromedriver from the selenium cache, clears any Gatekeeper quarantine, and ad-hoc signs it before running.
Requirements
- Ruby >= 3.0
License
MIT
