Markdowndocs
A drop-in mountable Rails engine that turns a folder of markdown files into a browsable documentation site with syntax highlighting, category grouping, and mode-based content filtering.
Features
- GitHub Flavored Markdown — Tables, task lists, strikethrough, footnotes, autolinks, and more via Commonmarker
- Syntax highlighting — Code blocks highlighted with Rouge (configurable theme)
- Category organization — Group docs into named categories for the index page
- Mode-based content filtering — Show different content to different audiences (e.g., "User Guide" vs "Developer Guide")
- Table of contents — Auto-generated from H2/H3 headings with anchor links
- YAML front matter — Set title, description, and mode availability per document
- Breadcrumb navigation — Category-aware breadcrumbs on each doc page
- Related documents — Sidebar links to other docs in the same category
- Responsive design — Tailwind CSS with mobile support
- Security — HTML sanitization, slug validation, directory traversal prevention
- Caching — Rendered markdown is cached with file-mtime-based invalidation
- i18n support — All UI strings are translatable
Requirements
- Ruby >= 3.2
- Rails >= 7.1
Installation
Add the gem to your application's Gemfile:
gem "markdowndocs"
Then run:
bundle install
rails generate markdowndocs:install
The generator will:
- Create
config/initializers/markdowndocs.rbwith default configuration - Create the
app/docs/directory for your markdown files - Mount the engine at
/docsin your routes
Configuration
Edit config/initializers/markdowndocs.rb to customize behavior:
Markdowndocs.configure do |config|
# Path to markdown files (default: Rails.root.join("app/docs"))
# config.docs_path = Rails.root.join("app", "docs")
# Category → slug mapping
config.categories = {
"Getting Started" => %w[welcome quickstart],
"Guides" => %w[authentication billing],
"Architecture" => %w[technical/architecture technical/billing]
}
# Available documentation modes (default: %w[guide technical])
# config.modes = %w[guide technical]
# Default mode (default: "guide")
# config.default_mode = "guide"
# Rouge syntax highlighting theme (default: "github")
# config.rouge_theme = "github"
# Cache expiry for rendered markdown (default: 1.hour)
# config.cache_expiry = 1.hour
# Allow a curated, safe subset of inline SVG (for hand-authored diagrams).
# Scripts, event handlers, and javascript: URIs are still stripped by the
# sanitizer. Default: false. (default: false)
# config.allow_svg = true
# Optional: Resolve current user's mode preference from database
# config.user_mode_resolver = ->(controller) {
# controller.send(:current_user)&.preferences&.docs_mode
# }
# Optional: Save user's mode preference to database
# config.user_mode_saver = ->(controller, mode) {
# controller.send(:current_user)&.preferences&.update!(docs_mode: mode)
# }
end
Bare slugs (e.g.,
"welcome") match files at the docs root. Path-prefixed slugs (e.g.,"technical/architecture") match files inside the named mode subdirectory. The prefix segment must match an entry inconfig.modes.
Configuration Options
| Option | Default | Description |
|---|---|---|
docs_path |
Rails.root.join("app/docs") |
Directory containing your markdown files |
categories |
{} |
Maps category names to arrays of document slugs |
modes |
%w[guide technical] |
Available viewing modes |
default_mode |
"guide" |
Mode shown by default |
rouge_theme |
"github" |
Syntax highlighting color scheme |
cache_expiry |
1.hour |
Cache duration for rendered markdown |
user_mode_resolver |
nil |
Lambda to load a user's mode preference from the database |
user_mode_saver |
nil |
Lambda to persist a user's mode preference to the database |
Writing Documentation
Create markdown files in app/docs/. The filename (without .md) becomes the URL slug — app/docs/quickstart.md is served at /docs/quickstart.
Front Matter
Add optional YAML front matter to set metadata:
---
title: "Quick Start Guide"
description: "Get up and running in five minutes"
audience:
- guide
- technical
modes:
- guide
- technical
default_mode: guide
---
# Quick Start Guide
Your content here...
If front matter is omitted, the title is extracted from the first H1 heading and the description from the first paragraph.
Audience Filtering by Filesystem Path
The recommended way to scope a whole document to a single audience is to
place it inside a subdirectory whose name matches an entry in
config.modes. Files at the docs root are shared — visible in every
mode.
app/docs/
├── getting_started.md → shared, visible in every mode
├── billing.md → shared
└── technical/
├── architecture.md → technical mode only
└── billing.md → technical mode only
URLs follow the filesystem layout: app/docs/billing.md is served at
/docs/billing; app/docs/technical/billing.md is served at
/docs/technical/billing. Both URLs are stable and shareable.
Subdirectories whose name does not match a configured mode are ignored by document discovery, with a one-line warning at boot.
Audience Filtering by Frontmatter (deprecated)
The audience: frontmatter key from v0.6.0 still works in v0.7.x but is
deprecated. A warning is logged the first time each affected file is
read. Move the file into the matching mode subdirectory and remove the
audience: key. See the migration guide below.
audience: technical # deprecated — move to app/docs/technical/
audience: [guide, technical] # deprecated — keep at root, drop the key
# omit `audience:` # still works for shared docs at root
The audience: key is scheduled for removal in v1.0.0.
Mode Blocks
Use HTML comments to show content only in specific modes:
## Setup
This paragraph appears in all modes.
<!-- mode: guide -->
Follow these steps to get started:
1. Click the "Install" button
2. Follow the on-screen prompts
<!-- /mode -->
<!-- mode: technical -->
Add the dependency to your Gemfile and run the install generator:
\`\`\`bash
bundle add markdowndocs
rails generate markdowndocs:install
\`\`\`
<!-- /mode -->
Syntax Highlighting
Code blocks are automatically syntax-highlighted. Specify the language after the opening fence:
```ruby
def hello
puts "Hello, world!"
end
```
```javascript
function hello() {
console.log("Hello, world!");
}
```
Supported languages include Ruby, JavaScript, Python, Bash, YAML, JSON, HTML, CSS, SQL, and many more.
Categories
To organize docs on the index page, map category names to slugs in your configuration:
config.categories = {
"Getting Started" => %w[welcome quickstart],
"Guides" => %w[authentication deployment]
}
Documents not assigned to a category will appear in an "Uncategorized" group.
Rendering Pipeline
When a documentation page is requested, the markdown goes through these stages:
- File reading — Load raw markdown from
app/docs/ - Mode filtering — Strip content blocks not matching the current viewing mode
- Commonmarker parsing — Parse with GFM extensions (tables, strikethrough, autolinks, footnotes, task lists)
- Syntax highlighting — Apply Rouge highlighting to fenced code blocks
- HTML sanitization — Whitelist-based sanitization strips dangerous tags and attributes
- Heading anchors — Inject
idattributes on H2/H3 headings for TOC linking - Caching — Store rendered HTML keyed by file path, mtime, and mode
Caching
Rendered HTML is cached using Rails.cache with a composite cache key based on file path, file modification time, and viewing mode. Cache is automatically invalidated when file content changes.
To manually clear documentation caches:
# In Rails console
Rails.cache.clear
# Or delete matched keys
Rails.cache.delete_matched("markdown_*")
The default cache expiry is 1 hour, configurable via config.cache_expiry.
Security
Directory Traversal Prevention
Slugs are validated to contain only alphanumeric characters, hyphens, and underscores. Patterns like ../ and / are rejected, ensuring only files within app/docs/ are accessible.
HTML Sanitization
All rendered HTML is passed through a whitelist-based sanitizer. Safe tags (headings, paragraphs, code blocks, lists, links, images, tables) are allowed. Script tags, event handlers, and dangerous attributes are stripped.
YAML Parsing
Front matter is parsed with YAML.safe_load to prevent code execution.
Best Practices
- Start with H1 — Every document should have exactly one H1 heading at the top
- Write descriptive first paragraphs — The first paragraph becomes the card description on the index page
- Use meaningful filenames — The filename becomes the URL slug; use kebab-case (e.g.,
api-reference.md) - Include code examples — Use fenced code blocks with a language specifier for syntax highlighting
- Link between docs — Reference other docs with relative links:
[See authentication](/docs/authentication) - Keep files focused — Break large topics into multiple documents
- Use sequential headings — Don't skip levels (e.g., H1 to H3); this ensures proper TOC generation
Troubleshooting
Document Not Appearing
- Check the filename matches the slug in your category mapping
- Verify the file has a
.mdextension - Ensure the file is in the
app/docs/directory - Restart the server if you modified the initializer
Syntax Highlighting Not Working
- Verify the code fence has a language specified (e.g.,
ruby`) - Check the Rouge theme is configured in the initializer
- Clear the cache:
Rails.cache.clear
404 Errors
- Verify the slug matches the filename (use kebab-case)
- Check the file exists in
app/docs/ - Look for typos in the slug or filename
Development
After checking out the repo, run bin/setup to install dependencies. Then run the tests:
bundle exec rspec
Releasing
Update
CHANGELOG.mdwith a new## [x.y.z] - YYYY-MM-DDsection and add a comparison link at the bottom.Bump the version in
lib/markdowndocs/version.rb:
module Markdowndocs
VERSION = "x.y.z"
end
- Commit and tag the release:
git add lib/markdowndocs/version.rb CHANGELOG.md
git commit -m "Release vx.y.z"
git tag vx.y.z
git push origin main --tags
Pushing the tag triggers the GitHub Actions release workflow, which builds and publishes the gem to RubyGems automatically.
Migrating from v0.6.x to v0.7.0
URL stability. Every URL from v0.6.x continues to resolve. Hosts
that upgrade without moving files see zero URL changes. Path-based
routing only introduces new URLs (/docs/<mode>/<slug>) when you
explicitly relocate files into mode subdirectories.
If you don't use audience: today
No action required. Adopt the new convention at your leisure.
If you use audience: <single-mode>
For each affected doc:
- app/docs/foo.md
- ---
- audience: technical
- ---
+ app/docs/technical/foo.md
+ (no `audience:` key)
The deprecation warning surfaces the suggested target path.
If you use audience: [guide, technical]
The doc is multi-audience — drop the key, the root file is shared:
app/docs/foo.md
- ---
- audience: [guide, technical]
- ---
+ (no `audience:` key)
config.categories for mode-scoped docs
Prefix slugs with the mode subdirectory:
config.categories = {
- "Architecture" => %w[architecture data_model]
+ "Architecture" => %w[technical/architecture data_model]
}
Bare slugs continue to mean "the doc at the root with this name."
Contributing
Bug reports and pull requests are welcome on GitHub at github.com/dschmura/markdowndocs.
License
The gem is available as open source under the terms of the MIT License.