marquery
Marquery
A compile-time markdown file query engine for Crystal. Drop your markdown files in a directory, define a query class, and get a type-safe, filterable, pagination-ready collection baked right into your binary. No database, no runtime parsing.
- Low runtime overhead. Markdown is parsed at compile time and embedded in the binary.
- Frontmatter support. YAML frontmatter for metadata like title, description, tags, and custom fields.
- Chainable queries. Filter, sort, reverse, shuffle, navigate, and more.
- Built-in HTML rendering. Cmark GFM out of the box, or bring your own renderer.
- Framework-friendly. Works with Lucky, Kemal, or any Crystal app.
[!Note] The original repository is hosted at Codeberg. The GitHub repo is just a mirror.
Quick start
require "marquery"
class Blog::PostQuery
include Marquery::Query
end
query = Blog::PostQuery.new
query.filter(&.active?).sort_by(&.title).all
query.find("my-first-post").to_html
Add your markdown files to marquery/blog_post/ and you're good to go:
marquery/blog_post/20260320_my_first_post.md
marquery/blog_post/20260323_another_post.md
That's it.
Installation
-
Add the dependency to your
shard.yml:dependencies: marquery: codeberg: fluck/marquery -
Run
shards install
Markdown files
Entries are markdown files with a date-prefixed filename:
marquery/blog_post/20260320_first_post.md
The date (YYYYMMDD) and name are extracted from the filename. The name becomes the slug (hyphenated) and title (humanized). An optional YAML frontmatter block can override these and add custom fields:
---
title: The very first post
description: >-
This is the first post.
active: true
tags:
- crystal
- web
---
The body of the post goes here.
[!NOTE] Supported frontmatter types:
Bool,Int32,Float64,String,Time, andArray(String).
Models
By default, entries are deserialized into Marquery::Entry. For custom fields, define a struct that includes Marquery::Model:
struct Blog::Post
include Marquery::Model
getter tags : Array(String) = [] of String
end
The base fields provided by Marquery::Model are:
slug(String)title(String)description(String?)content(String)date(Time)active?(Bool, defaults totrue).
The original markdown file path is also available if you add it to your model:
struct Blog::Post
include Marquery::Model
getter source : String
end
Queries
Create a query class and include Marquery::Query:
class Blog::PostQuery
include Marquery::Query
model Blog::Post
end
The data directory is derived from the class name (without the Query suffix):
Blog::PostQuery->marquery/blog_post/*.mdItemQuery->marquery/item/*.mdNews::ArticleQuery->marquery/news_article/*.md
Querying
query = Blog::PostQuery.new
query.all # all entries
query.first # first entry (raises if empty)
query.first? # first entry or nil
query.last # last entry (raises if empty)
query.last? # last entry or nil
query.find("first-post") # find by slug (raises if not found)
query.find?("first-post") # find by slug or nil
query.previous(post) # previous entry or nil
query.next(post) # next entry or nil
Error handling
Query methods raise typed exceptions that you can rescue from:
begin
Blog::PostQuery.new.find("nonexistent")
rescue ex : Marquery::EntryNotFound
puts ex.message # => "Entry not found: nonexistent"
end
All library exceptions inherit from Marquery::Error, so you can also catch them all:
rescue ex : Marquery::Error
# ...
end
Filtering, sorting, and chaining
filter, sort_by, reverse, and shuffle all return self, so they chain naturally:
Blog::PostQuery.new
.filter(&.active?)
.filter { |post| post.date >= 1.month.ago }
.filter(&.tags.includes?("crystal"))
.sort_by(&.title)
.all
[!NOTE] Since
filteris just a thin wrapper aroundArray#select, you can express any condition.
Default sort order
Entries are sorted by date descending by default. Use order_by to change the default:
class Blog::PostQuery
include Marquery::Query
order_by date, Marquery::Order::ASC
end
Or sort by a different field:
class Blog::PostQuery
include Marquery::Query
order_by title
end
Pagination
The all method returns a plain Array, so it works with any array-based pagination solution.
Lucky has built-in array pagination with paginate_array:
class Blog::Index < BrowserAction
get "/blog" do
pages, posts = paginate_array(Blog::PostQuery.new.all)
html Blog::IndexPage, posts: posts, pages: pages
end
end
For Kemal and other frameworks, pager is a good option:
require "pager/collections/array"
get "/blog" do |env|
current_page = env.params.query["page"]?.try(&.to_i) || 0
posts = Blog::PostQuery.new.all.paginate(current_page, 10)
# ...
end
Index pages
Each query can have an index page with metadata for the collection itself (e.g. page title, meta description). Create a _index.md file in the data directory:
marquery/blog_post/_index.md
marquery/blog_post/_index/og-image.png
---
title: Blog
description: Thoughts on Crystal and web development.
---
Welcome to the blog.
Access it via the query class:
Blog::PostQuery.index.title # => "Blog"
Blog::PostQuery.index.description # => "Thoughts on Crystal and web development."
Blog::PostQuery.index.to_html # => "<p>Welcome to the blog.</p>\n"
If no _index.md exists, the index returns empty defaults.
Custom index model
For custom fields, create a struct that includes Marquery::Collection and assign it with the index macro:
struct Blog::PostIndex
include Marquery::Collection
getter subtitle : String?
end
class Blog::PostQuery
include Marquery::Query
index Blog::PostIndex
end
HTML rendering
Model instances have a to_html method that renders the content field to HTML using Cmark (GitHub Flavored Markdown) by default:
post = Blog::PostQuery.new.find("first-post")
post.to_html # => "<p>The body of the post goes here.</p>\n"
Custom renderer
To use a different markdown renderer, create a struct that includes Marquery::MarkdownToHtml:
struct MyRenderer
include Marquery::MarkdownToHtml
def markdown_to_html(content : String) : String
MyMarkdownLib.render(content)
end
end
Then declare it on your model with to_html:
struct Blog::Post
include Marquery::Model
to_html MyRenderer
end
Pre-processing content
Marquery passes raw markdown through process_content before handing it to the renderer. The default implementation rewrites asset:KEY URI references to their resolved paths, so you can write asset references inline using ordinary markdown link and image syntax:

See the [spec sheet](asset:specs.pdf) for details.
These get rewritten to whatever asset("hero.jpg") and asset("specs.pdf") resolve to. Unknown keys raise Marquery::AssetNotFound, matching the behavior of the asset helper.
Plugging in a templating engine
Override process_content on your model to do more, such as running the content through a templating engine. The hook runs inside the model instance, so self, frontmatter fields, and asset(...) are all available.
For example, with Crinja:
require "crinja"
struct Blog::Post
include Marquery::Model
def process_content(raw : String) : String
Crinja.new.from_string(raw).render({"entry" => self})
end
end
Then in your markdown:
---
title: First post
---
# {{ entry.title }}
 }})
Marquery deliberately stays out of the templating decision. Use Crinja, Liquid, ECR (compile-time only), or roll your own. Whatever process_content returns is what gets passed to the markdown renderer.
Markdown in pages and components
Include Marquery::MarkdownHelper in pages or components of your app to get a convenient markdown method:
class Blog::ShowPage
include Marquery::MarkdownHelper
def content
div do
# Accepts a String
markdown "**Something**"
# Or an instance of Marquery::Model
markdown post
end
end
end
[!NOTE] In Lucky apps, the output is automatically wrapped in
raw.
To use a custom renderer in pages, use the markdown_renderer macro:
class Blog::ShowPage
include Marquery::MarkdownHelper
markdown_renderer MyRenderer
end
Assets
Place images and media files in a directory matching the markdown filename (without the .md extension):
marquery/blog_post/20260320_first_post.md
marquery/blog_post/20260320_first_post/hero.png
marquery/blog_post/20260320_first_post/diagram.svg
The assets are available on the entry at compile time:
post = Blog::PostQuery.new.find("first-post")
post.asset("hero.png") # => "/marquery/blog_post/20260320_first_post/hero.png"
post.asset?("missing") # => nil
post.assets # => {"hero.png" => "marquery/blog_post/...", ...}
[!NOTE] Supported file types:
.avif,.gif,.jpeg,.jpg,.mp3,.mp4,.ogg,.png,.svg,.webm,.webp. Dotfiles are ignored.
Serving assets
To serve asset files over HTTP, add Marquery::AssetHandler to your middleware:
require "marquery/asset_handler"
# Lucky
class AppServer < Lucky::BaseAppServer
def middleware : Array(HTTP::Handler)
[
# ... other handlers ...
Marquery::AssetHandler.new(Blog::PostQuery.dir, News::ArticleQuery.dir),
# ...
] of HTTP::Handler
end
end
# Kemal
add_handler Marquery::AssetHandler.new(Blog::PostQuery.dir)
The handler serves files from the configured directories and falls through for anything else.
Configuring the data directory
The default data directory is marquery/. To change it globally, annotate the Marquery module:
# e.g. config/marquery.cr
@[Marquery::Dir("data")]
module Marquery
end
Individual query classes can override the directory:
@[Marquery::Dir("db/content")]
class Blog::PostQuery
include Marquery::Query
end
Multi-language content
For multilingual sites, create a query class per language and extract shared configuration into a mixin:
module Blog::PostQueryMethods
model Blog::Post
order_by date
end
class Blog::Post::EnQuery
include Marquery::Query
include Blog::PostQueryMethods
end
class Blog::Post::NlQuery
include Marquery::Query
include Blog::PostQueryMethods
end
Each class gets its own directory derived from the class name:
marquery/blog_post_en/20260320_first_post.md
marquery/blog_post_nl/20260320_eerste_bericht.md
Then select the query based on the current locale:
query = case locale
when "nl" then Blog::Post::NlQuery.new
else Blog::Post::EnQuery.new
end
To avoid duplicating assets across locales, use the @[Marquery::Assets] annotation to point to a shared assets directory:
@[Marquery::Assets("marquery/blog_post")]
class Blog::Post::EnQuery
include Marquery::Query
include Blog::PostQueryMethods
end
@[Marquery::Assets("marquery/blog_post")]
class Blog::Post::NlQuery
include Marquery::Query
include Blog::PostQueryMethods
end
Assets are organized by date (matching the entry filename prefix) and an optional _shared directory for assets common to all entries:
marquery/blog_post/
├── _shared/logo.svg
├── 20260320/hero.png
└── 20260325/diagram.svg
These are merged with any per-entry assets. When names collide, per-entry assets take precedence over date-based, which take precedence over _shared.
To serve shared assets over HTTP, pass assets_dir to the handler:
Marquery::AssetHandler.new(
Blog::Post::EnQuery.dir,
Blog::Post::EnQuery.assets_dir,
)
Contributing
We use conventional commits for our commit messages, so please adhere to that pattern.
- Fork it (https://codeberg.org/fluck/marquery/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'feat: new feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Wout - creator and maintainer
marquery
- 2
- 0
- 0
- 1
- 3
- 6 days ago
- March 28, 2026
Sun, 12 Apr 2026 16:32:58 GMT