Marquery
A markdown query engine for Ruby. Load markdown files with YAML frontmatter from a conventional directory layout, query them through a chainable API, resolve associated assets, and render content to HTML.
This is a Ruby companion to the Crystal marquery shard. The Crystal version embeds everything at compile time via macros; the Ruby version does the equivalent work at runtime with memoization and an opt-in eager-load hook for production boot.
[!Note] The original repository is hosted at Codeberg. The GitHub repo is just a mirror.
Installation
Add this to your Gemfile:
gem "marquery"
Then run bundle install. Ruby 3.2 or newer is required.
Quickstart
1. Lay out your content
marquery/
blog_post/
_index.md
20260320_first_post.md
20260320_first_post/
hero.png
20260325_second_post.md
The directory name (blog_post) is derived from your query class name.
Filenames must start with a YYYYMMDD_ date prefix.
2. Define a model (optional)
class Post
include Marquery::Model
attribute :tags, type: :array, default: []
attribute :author
attribute :featured, type: :bool, default: false
end
If you do not declare a model, Marquery::Entry is used as a default. Standard
fields are always available: slug, title, description, content, date,
active?, source, assets.
3. Define a query
class PostQuery
include Marquery::Query
model Post
order_by :date, :desc
end
4. Query and render
PostQuery.new.all
PostQuery.new.find("first-post")
PostQuery.new.filter(&:active?).sort_by(&:title).first
PostQuery.index_entry # _index.md as a Marquery::Index
post = PostQuery.new.find("first-post")
post.to_html
post.asset("hero.png")
Chainable methods return new query instances, so chains are safe to reuse:
recent = PostQuery.new.filter(&:active?)
recent.size # unaffected by later chaining
recent.sort_by(&:title).first # returns the first by title
Queries
Every filter or sort method returns a new query, so chains never mutate the original.
query = PostQuery.new
query.all # Array of entries
query.first # nil if empty
query.last # nil if empty
query.size # entry count
query.find("first-post") # raises Marquery::EntryNotFound when missing
query.find_by_slug("first-post") # returns nil when missing
query.previous(post) # previous entry, or nil at the start
query.next(post) # next entry, or nil at the end
Chainable methods, all returning a new query:
query.filter(&:active?)
query.reject(&:active?)
query.sort_by(&:title)
query.reverse
query.shuffle
query.shuffle(random: Random.new(42))
Marquery::Query includes Enumerable, so each, map, count, any?,
take, and friends are available. Those return plain Arrays, matching
Enumerable conventions. Use the chainable methods above when you want to
keep working with a query.
Error handling
Marquery raises typed exceptions that all inherit from Marquery::Error:
begin
PostQuery.new.find("nonexistent")
rescue Marquery::EntryNotFound => exception
exception. # => "Entry not found: nonexistent"
end
begin
post.asset("missing.png")
rescue Marquery::AssetNotFound => exception
exception. # => "Asset not found: missing.png"
end
Marquery::ParseError is raised at load time when frontmatter or filenames
cannot be parsed. Catching Marquery::Error picks up all three.
Configuration
Marquery.configure do |c|
c.data_dir = "content"
c.preprocessor = ->(raw, entry) do
Liquid::Template.parse(raw).render("entry" => entry)
end
end
Eager loading in production
# config/initializers/marquery.rb (Rails)
Marquery.eager_load!
This walks every registered query class and parses files upfront so the first request does not pay the loading cost.
Custom index model
By default the _index.md file is parsed into a Marquery::Index with
the standard title, description, and content fields. For extra
metadata on the index page, define your own type that includes
Marquery::Collection and declare attributes the same way as on a model:
class PostIndex
include Marquery::Collection
attribute :subtitle
attribute :featured_slugs, type: :array, default: []
end
class PostQuery
include Marquery::Query
index PostIndex
end
---
title: Blog
subtitle: Thoughts on Ruby and Hanami
featured_slugs:
- first-post
- third-post
---
Welcome to the blog.
PostQuery.index_entry.subtitle # => "Thoughts on Ruby and Hanami"
PostQuery.index_entry.featured_slugs # => ["first-post", "third-post"]
PostQuery.index_entry.to_html # => "<p>Welcome to the blog.</p>\n"
If no _index.md exists, index_entry returns an empty Marquery::Index
(or your custom collection) with default field values.
Custom rendering
The default renderer uses commonmarker for GitHub-flavored markdown. Swap it out per model:
class MyRenderer
include Marquery::MarkdownToHtml
def markdown_to_html(content)
Kramdown::Document.new(content).to_html
end
end
class Post
include Marquery::Model
renderer MyRenderer
end
Preprocessing
Two hooks, with per-model winning over global:
# Global default.
Marquery.configure do |c|
c.preprocessor = ->(raw, entry) { ... }
end
# Per-model override.
class Post
include Marquery::Model
def process_content(raw)
rendered = Liquid::Template.parse(raw).render("entry" => self)
rewrite_assets(rendered)
end
end
If you override process_content, you are in charge of asset rewriting. Call
rewrite_assets(content) to opt back in.
Shared and multi-language assets
For multilingual sites, point several query classes at a shared assets directory. Each language gets its own content tree; assets live once.
class Blog::EnQuery
include Marquery::Query
dir "blog_en"
assets_dir "blog_assets"
end
class Blog::NlQuery
include Marquery::Query
dir "blog_nl"
assets_dir "blog_assets"
end
Both dir and assets_dir resolve under Marquery.config.data_dir (default
marquery/). The example above looks at:
marquery/blog_en/ # English entries
marquery/blog_nl/ # Dutch entries
marquery/blog_assets/ # shared assets
├── _shared/
│ └── logo.svg # available on every entry
├── 20260320/
│ └── hero.png # available on entries dated 20260320
└── 20260325/
└── diagram.svg
Assets merge in three layers, each overriding the previous one when keys collide:
_shared/(available to every entry)<YYYYMMDD>/(available to entries with that date prefix)- The per-entry sibling directory next to the markdown file
So a per-entry banner.png wins over a date-scoped banner.png, which in
turn wins over a _shared/banner.png. This lets you provide sensible
defaults and override per-entry when needed.
Framework integration
Marquery ships two opt-in pieces for integrating with web frameworks:
Marquery::Helpersexposes amarkdown(content)method that accepts a String or anyMarquery::Renderable(model or collection instance).Marquery::AssetHandleris Rack middleware that serves files from one or more marquery data directories with path-traversal protection.
The two snippets below are everything you need. The framework-specific sections after them only differ in where you wire them up.
# Renders a String or a Marquery::Renderable instance to HTML.
Marquery::Helpers.markdown(post) # => "<h1>...</h1>"
Marquery::Helpers.markdown("# Hello") # => "<h1>...</h1>"
Marquery::Helpers.markdown(post, renderer: MyRenderer)
require "marquery/asset_handler"
# Rack middleware. Pass one or more directories to serve files from.
# `data_path` and `assets_path` are computed under Marquery.config.data_dir,
# so they stay in sync if you change the global root.
use Marquery::AssetHandler, PostQuery.data_path, PostQuery.assets_path
Hanami
Include Marquery::Helpers in your slice's view helpers and mount the asset
handler as middleware. Both Hanami's app config and route-scoped middleware
work; pick whichever fits your slice layout.
# app/views/helpers.rb
module MyApp
module Views
module Helpers
include Marquery::Helpers
end
end
end
# config/app.rb
require "marquery/asset_handler"
require_relative "../app/queries/post_query"
module MyApp
class App < Hanami::App
config.middleware.use Marquery::AssetHandler, PostQuery.data_path
end
end
In templates, mark the output as safe HTML with Hanami's raw helper:
<%= raw markdown(post) %>
Any Ruby app
Marquery::Helpers is a plain module. extend it on the object that needs
the helper, or include it in a class.
class PostPresenter
include Marquery::Helpers
def initialize(post)
@post = post
end
def html
markdown(@post)
end
end
If you serve HTTP through Rack (Sinatra, Roda, plain Rack), mount the asset
handler in config.ru:
require "marquery/asset_handler"
require_relative "post_query"
use Marquery::AssetHandler, PostQuery.data_path
run MyApp
Rails
Mix Marquery::Helpers into ApplicationHelper so markdown is available
across views.
# app/helpers/application_helper.rb
module ApplicationHelper
include Marquery::Helpers
end
<%= raw markdown(@post) %>
For serving assets, either copy marquery/ under public/, point Propshaft
at it, or add the Rack middleware:
# config/application.rb
require "marquery/asset_handler"
config.middleware.use Marquery::AssetHandler, PostQuery.data_path
Pagination
query.all returns a plain Array, so any pagination library that takes an
Array works.
Pagy is the lightest option and works in Hanami, Rails, Sinatra, Roda, and plain Rack:
@pagy, @posts = pagy_array(PostQuery.new.all, items: 10)
For Rails, Kaminari also paginates arrays:
@posts = Kaminari.paginate_array(PostQuery.new.all)
.page(params[:page]).per(10)
For plain Ruby, slice it yourself:
pages = PostQuery.new.all.each_slice(10).to_a
Serializing entries
Both Marquery::Model and Marquery::Collection expose to_h, returning a
plain Hash of standard plus declared attributes. Pipe it through any JSON
library you like:
require "json"
JSON.generate(post.to_h)
Development
bundle install
bundle exec rspec
bundle exec rubocop
License
MIT