FMRepo provides an Active Record-like interface for managing Markdown files with YAML front matter in static site repositories. Perfect for Jekyll-style collections and custom static site generators.
- Active Record-style API: Familiar
where,order,limit,find,create!methods - Associations: Define
belongs_toandhas_manyrelationships via front matter foreign keys - Chainable queries: Build complex queries with immutable relation objects
- Type-per-directory: Define one model class per collection/directory
- Custom naming rules: Control file naming and collision resolution
- Safe filesystem operations: Atomic writes, path validation, and collision handling
- Flexible predicates: Query with equality, inclusion, comparisons, regex, and custom predicates
- Custom relations: Extend with domain-specific query methods
Add this line to your application's Gemfile:
gem 'fmrepo'And then execute:
bundle installOr install it yourself as:
gem install fmreporequire 'fmrepo'
# Define a model for your collection with repository path
class Place < FMRepo::Record
repository "/path/to/site" # Configure repository at class definition
scope glob: "_places/**/*.{md,markdown}"
naming do |front_matter:, **|
slug = FMRepo.slugify(front_matter["title"])
"_places/#{slug}.md"
end
def title = self["title"]
def county = self["county"]
end
# Query records
Place.where("county" => "King").order("title").limit(10).each do |place|
puts place.title
end
# Create new records
place = Place.create!(
{ "title" => "Seattle", "county" => "King" },
body: "Seattle is the largest city in Washington state."
)
# Update and save
place["population"] = 750_000
place.save!
# Delete records
place.destroyWhile FMRepo provides an Active Record-like interface, there are key differences:
| Feature | Active Record | FMRepo |
|---|---|---|
| Data Source | Database tables | Markdown files with front matter |
| Model Configuration | Database connection configured globally | Repository path configured per model class |
| Record Identity | Primary key (usually id column) |
File path relative to repository root |
| Schema | Defined in migrations | Flexible YAML front matter (no schema) |
| Relationships | Associations (has_many, belongs_to) | belongs_to and has_many via front matter foreign keys |
| Transactions | Database transactions | Not supported (file-per-record) |
| Callbacks | Before/after hooks | Not supported (v1) |
| Validations | Built-in validation framework | Not supported (v1) |
| Query Interface | SQL-based with rich DSL | File-based with predicates |
| Persistence | Row in database | Markdown file with YAML front matter |
- Chainable query interface (
where,order,limit) - Instance methods for persistence (
save!,destroy,reload) - Class methods for finding records (
find,find_by,all) - Attribute accessors (front matter fields via
[]and[]=)
- Use FMRepo for static site generators, documentation sites, or file-based content management
- Use Active Record for traditional web applications with relational data and complex queries
The FMRepo::Repository class handles all filesystem operations:
repo = FMRepo::Repository.new(root: "/path/to/site")Features:
- Path safety: All operations are validated to be within the repository root
- Atomic writes: Files are written atomically to prevent corruption
- Collision resolution: Automatically handles filename conflicts
FMRepo::Record is the base class for your models. Each record represents a single Markdown file with front matter.
class Post < FMRepo::Record
repository "/path/to/site" # Configure repository path
scope glob: "_posts/**/*.md"
naming do |front_matter:, **|
date = front_matter["date"]&.strftime("%Y-%m-%d") || Time.now.strftime("%Y-%m-%d")
slug = FMRepo.slugify(front_matter["title"])
"_posts/#{date}-#{slug}.md"
end
endRepository Configuration: Specify the repository path at class definition:
class Post < FMRepo::Record
repository "/path/to/site" # Path string
# or
repository FMRepo::Repository.new(root: "/path/to/site") # Repository instance
endScoping: Define which files belong to this model:
scope glob: "_posts/**/*.md", exclude: ["_posts/drafts/**"]Naming rules: Control how new files are named:
naming do |front_matter:, body:, repo:, **opts|
# Return a repo-relative path string
"_posts/#{FMRepo.slugify(front_matter["title"])}.md"
endFMRepo::Relation provides chainable query interface:
# Basic queries
Post.where("published" => true)
.order("date", :desc)
.limit(10)
.to_a
# With predicates
Post.where("tags" => FMRepo.includes("ruby"))
.where("date" => FMRepo.gt(Date.new(2024, 1, 1)))Custom relations: Add domain-specific query methods:
class PostRelation < FMRepo::Relation
def published
where("published" => true)
end
def recent(days = 7)
where("date" => FMRepo.gte(Date.today - days))
end
end
class Post < FMRepo::Record
relation_class PostRelation
end
# Usage
Post.published.recent.to_aall- Returns a relation for all recordswhere(criteria)- Filter records by criteriaorder(field, direction)- Sort by field (:ascor:desc)limit(n)- Limit number of resultsoffset(n)- Skip first n resultsfind(id)- Find by repo-relative pathfind_by(criteria)- Find first matching recordcreate!(attrs, body:, path:)- Create and save a new record
save!- Save changes to diskdestroy- Delete the filereload- Refresh from disk[key]- Get front matter value[key]=- Set front matter valuebody- Get/set body contentid- Get repo-relative pathpersisted?- Check if savednew_record?- Check if newdirty?- Check if record has unsaved changes
Built-in predicates for querying:
# Inclusion
FMRepo.includes("ruby") # Array/String includes value
FMRepo.in_set(["a", "b", "c"]) # Value is in set
# Presence
FMRepo.present # Not nil/empty
# Pattern matching
FMRepo.matches(/regex/) # String matches regex
# Comparisons
FMRepo.gt(5) # Greater than
FMRepo.gte(5) # Greater than or equal
FMRepo.lt(5) # Less than
FMRepo.lte(5) # Less than or equal
FMRepo.between(1, 10) # Between valuesQuery special built-in fields:
_id- Repo-relative path as string_path- Absolute path as string_rel_path- Repo-relative path as string_mtime- File modification time_model- Model class name
Place.where("_mtime" => FMRepo.gt(Time.now - 3600))
.order("_id")
.to_aFMRepo supports belongs_to and has_many associations for defining relationships between models using front matter foreign keys.
Define a belongs_to association to create a foreign key and lazy-loading accessor:
class Post < FMRepo::Record
repository "/path/to/site"
scope glob: "_posts/**/*.md"
belongs_to :author # Creates author_id foreign key and author accessor
# Or with explicit class name:
# belongs_to :creator, class_name: "Author"
naming do |front_matter:, **|
"_posts/#{FMRepo.slugify(front_matter['title'])}.md"
end
end
class Author < FMRepo::Record
repository "/path/to/site"
scope glob: "_authors/**/*.md"
naming do |front_matter:, **|
"_authors/#{FMRepo.slugify(front_matter['name'])}.md"
end
endUsage:
# Create an author
author = Author.create!(
{ "name" => "Alice Smith" },
body: "Author bio..."
)
# Create a post and associate it with the author
post = Post.new({ "title" => "My First Post" })
post.author = author # Sets author_id automatically
post.save!
# Or set the foreign key directly
post.author_id = author.id # Direct access to the foreign key
post.save!
# Access the foreign key value
puts post.author_id # => "_authors/alice-smith.md"
# Load a post and access its author (lazy-loaded)
loaded_post = Post.find("_posts/my-first-post.md")
puts loaded_post.author["name"] # => "Alice Smith"
# The association is cached after first access
author1 = loaded_post.author
author2 = loaded_post.author
author1.object_id == author2.object_id # => trueKey Features:
- Stores foreign key as
{name}_idin front matter (e.g.,author_id) - Creates
{name}_idand{name}_id=methods for direct foreign key access - Lazy-loads the associated record on first access
- Caches the loaded record for performance
- Returns
nilif the foreign key is not set or the record doesn't exist - Provides setter method to update both association and foreign key
- Setting
{name}_id=directly clears the cached association
Define a has_many association to retrieve all records that reference the current record:
class Author < FMRepo::Record
repository "/path/to/site"
scope glob: "_authors/**/*.md"
has_many :posts # Returns posts where post.author_id == self.id
# Or with explicit class name for irregular plurals:
# has_many :categories, class_name: "Category"
naming do |front_matter:, **|
"_authors/#{FMRepo.slugify(front_matter['name'])}.md"
end
end
class Post < FMRepo::Record
repository "/path/to/site"
scope glob: "_posts/**/*.md"
belongs_to :author
has_many :comments
naming do |front_matter:, **|
"_posts/#{FMRepo.slugify(front_matter['title'])}.md"
end
end
class Comment < FMRepo::Record
repository "/path/to/site"
scope glob: "_comments/**/*.md"
belongs_to :post
naming do |front_matter:, **|
"_comments/comment-#{SecureRandom.hex(4)}.md"
end
endUsage:
# Get all posts for an author
author = Author.find("_authors/alice-smith.md")
posts = author.posts.to_a
puts "#{author['name']} has #{posts.length} posts"
# Chain queries on the association
recent_posts = author.posts
.where("published" => true)
.order("date", :desc)
.limit(5)
.to_a
# Navigate nested associations
post = Post.find("_posts/my-first-post.md")
comments = post.comments.to_a
comment_authors = comments.map { |c| c.post.author["name"] }.uniqKey Features:
- Returns a chainable
Relationobject (not an array) - Filters by
{model_name}_idforeign key (e.g.,author_idfor Author model) - Supports all relation methods (
where,order,limit, etc.) - Returns an empty relation for unpersisted records (maintains chainability)
- Uses simple pluralization (strips trailing 's' from association name)
- Supports
class_name:option for explicit class resolution - Caches the relation for performance
Pluralization Note: The association uses simple pluralization by stripping the trailing 's'. For irregular plurals,
use the class_name: option:
has_many :categories, class_name: "Category"
has_many :addresses, class_name: "Address"You can navigate through multiple associations:
# From a comment, access the post's author
comment = Comment.find("_comments/comment-abc123.md")
author_name = comment.post.author["name"]
# Get all comments for all posts by an author
author = Author.find("_authors/alice-smith.md")
all_comments = author.posts.flat_map { |post| post.comments.to_a }Associations automatically resolve class names from the association name:
belongs_to :authorlooks for anAuthorclasshas_many :blog_postslooks for aBlogPostclass
You can override the inferred class name with the class_name: option:
belongs_to :creator, class_name: "Author"looks forAuthorinstead ofCreatorhas_many :categories, class_name: "Category"looks forCategoryinstead ofCategorie
The resolution tries:
- Global scope first (e.g.,
Object.const_get("Author")) - Local namespace if the model is namespaced (e.g.,
MyApp::Authorfor a model inMyApp)
If the associated class cannot be found, a NameError is raised.
# Each model class specifies its own repository
class Place < FMRepo::Record
repository "/path/to/site"
scope glob: "_places/**/*.md"
end
class Post < FMRepo::Record
repository "/path/to/site"
scope glob: "_posts/**/*.md"
end
class Organization < FMRepo::Record
repository "/path/to/site"
scope glob: "_organizations/**/*.md"
end
# Each model operates on its own scope
places = Place.all.to_a
posts = Post.where("published" => true).to_aCreate custom predicates for complex queries:
# Define a custom predicate
def published_in_year(year)
->(date) { date.is_a?(Date) && date.year == year }
end
# Use it
Post.where("date" => published_in_year(2024))FMRepo parses YAML front matter automatically:
---
title: Example Post
tags:
- ruby
- rails
published: true
---
This is the body content.Becomes:
post["title"] # => "Example Post"
post["tags"] # => ["ruby", "rails"]
post["published"] # => true
post.body # => "This is the body content.\n"When creating files, collisions are automatically resolved:
Place.create!({"title" => "Seattle"}) # => _places/seattle.md
Place.create!({"title" => "Seattle"}) # => _places/seattle-2.md
Place.create!({"title" => "Seattle"}) # => _places/seattle-3.mdFMRepo defines specific error classes:
FMRepo::Error- Base error classFMRepo::NotBoundError- Model not bound to repositoryFMRepo::NotFound- Record not foundFMRepo::UnsafePathError- Path outside repository rootFMRepo::ParseError- YAML parsing error
begin
Place.find("nonexistent.md")
rescue FMRepo::NotFound => e
puts "Not found: #{e.message}"
endRun RuboCop and the test suite:
script/testOr run them individually:
bundle exec rubocop
bundle exec rake test
ruby -Ilib:test test/integration_test.rbFMRepo can pick repositories by environment instead of configuring each model manually. The environment defaults to
FMREPO_ENV, then JEKYLL_ENV, then RACK_ENV, then RAILS_ENV, falling back to development.
# .fmrepo.yml
default:
development: /sites/dev
test: <tmp> # create a temp repo automatically
production: /sites/live
places:
production: /sites/live/_placesFMRepo automatically loads .fmrepo.yml from the current working directory on first access to FMRepo.config or
FMRepo.repository_registry, or when you use a model that needs repository configuration. If you keep the file elsewhere,
point the configuration at it explicitly:
# config/initializers/fmrepo.rb
FMRepo.configure do |c|
c.load_yaml('config/fmrepo.yml')
endclass Place < FMRepo::Record
repository_role :places # optional; defaults to :default
scope glob: '_places/**/*.md'
naming { |front_matter:, **| "_places/#{FMRepo.slugify(front_matter['title'] || 'untitled')}.md" }
endTesting with temporary repositories:
require 'fmrepo/test_helpers'
class Minitest::Test
def setup
@repo_override = FMRepo::TestHelpers.with_temp_repo # uses FMRepo.environment
end
def teardown
@repo_override&.cleanup
end
endAll models using the configured role now write to a disposable repo in tests without subclassing.
Best practices:
- Keep repository paths in
.fmrepo.yml; avoid callingrepositoryin production code unless you truly need an override. - Use roles (
repository_role :places) for collections that map to different roots; default role works for single-repo apps. - For tests, set the
testentry to<tmp>or wrap examples withFMRepo::TestHelpers.with_temp_repoto isolate filesystem writes. - Set
FMREPO_ENVexplicitly for non-Rails apps or scripts; Rails apps will pick upRAILS_ENV. - When you must override a single model (e.g., a one-off migration),
Model.repository('/path')still works and bypasses the registry for that class only.
After checking out the repo, use the provided scripts for development:
Set up your development environment:
script/bootstrapThis will ensure you have the correct Ruby version and all dependencies installed.
Run linting and tests together:
script/testOr run just the test suite:
bundle exec rake testRun the full CI build locally:
script/cibuildUpdate gems to their latest versions:
script/updateTo build the gem:
gem build fmrepo.gemspecThe script/ directory contains several helper scripts:
script/bootstrap- Set up development environmentscript/test- Run test suitescript/cibuild- Run CI buildscript/update- Update dependenciesscript/ensure-*- Ensure specific tools are installed (bundler, ruby, homebrew, etc.)
Bug reports and pull requests are welcome on GitHub at https://github.com/calef/fmrepo.
The gem is available as open source under the terms of the MIT License.