marquery 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +473 -0
- data/lib/marquery/asset_handler.rb +60 -0
- data/lib/marquery/attributable.rb +70 -0
- data/lib/marquery/collection.rb +38 -0
- data/lib/marquery/entry.rb +9 -0
- data/lib/marquery/error.rb +19 -0
- data/lib/marquery/helpers.rb +26 -0
- data/lib/marquery/index.rb +9 -0
- data/lib/marquery/markdown_to_html.rb +9 -0
- data/lib/marquery/model.rb +60 -0
- data/lib/marquery/order.rb +16 -0
- data/lib/marquery/parser.rb +144 -0
- data/lib/marquery/query.rb +189 -0
- data/lib/marquery/registry.rb +24 -0
- data/lib/marquery/renderable.rb +54 -0
- data/lib/marquery/renderer.rb +28 -0
- data/lib/marquery/version.rb +5 -0
- data/lib/marquery.rb +47 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0dc9fe8c874e5943dc0e56c9f986e6352e33630d067727df43ce36d3c2677886
|
|
4
|
+
data.tar.gz: 1f6791ac261d546ee5361c81f1c447f693983785450ce897660f4dca337ffe19
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 96e7b6bbc81e5965232c8e0de1dacfd2caa16bd9ba0afdb7c9a1f648cdc42636fb73ff4d38f831369788e19e6e519a3cb28806ca87117a8b3380a5a4588272b9
|
|
7
|
+
data.tar.gz: a5f1b3ad546b38860ec3a287e7436fd4cb35d30fdb3c43df5d9e89b113bc1f1d934cf373d8f91e8c5b6f0ed5a367974796ab1d7bd7d5b039e216f033f37d0000
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial scaffolding inspired by the
|
|
13
|
+
[Crystal marquery shard](https://codeberg.org/fluck/marquery.cr).
|
|
14
|
+
- `Marquery::Model` with standard fields (`slug`, `title`, `description`,
|
|
15
|
+
`content`, `date`, `active?`, `source`, `assets`) and an `attribute` DSL
|
|
16
|
+
for declaring frontmatter-backed fields with optional type coercion.
|
|
17
|
+
- `Marquery::Entry` as the default model.
|
|
18
|
+
- `Marquery::Collection` and `Marquery::Index` for `_index.md` metadata,
|
|
19
|
+
including the same `attribute` DSL for custom index types.
|
|
20
|
+
- `Marquery::Renderable` with `assets`, `asset`, `asset?`, `rewrite_assets`,
|
|
21
|
+
`process_content`, `to_html`, and a per-class `renderer` DSL.
|
|
22
|
+
- `Marquery::Renderer` defaulting to Commonmarker (GitHub Flavored Markdown).
|
|
23
|
+
- `Marquery::MarkdownToHtml` interface for plugging in alternative
|
|
24
|
+
markdown libraries.
|
|
25
|
+
- `Marquery::Query` with class-level DSL (`model`, `index`, `order_by`,
|
|
26
|
+
`dir`, `assets_dir`) and a chainable, immutable instance API
|
|
27
|
+
(`all`, `find`, `find_by_slug`, `filter`, `reject`, `sort_by`, `reverse`,
|
|
28
|
+
`shuffle`, `previous`, `next`, `first`, `last`, `size`). Queries include
|
|
29
|
+
`Enumerable`.
|
|
30
|
+
- `Marquery::Parser` for loading entries and `_index.md` at runtime with
|
|
31
|
+
YAML frontmatter and asset directories (per-entry plus `_shared` and
|
|
32
|
+
dated directories under a shared `assets_dir`).
|
|
33
|
+
- `Marquery::Registry` and `Marquery.eager_load!` for opt-in warmup at
|
|
34
|
+
boot.
|
|
35
|
+
- `Marquery::Configuration` accessed via `Marquery.configure` block, with
|
|
36
|
+
`data_dir` and `preprocessor` keys.
|
|
37
|
+
- `Marquery::Helpers` module exposing `markdown(content)` for use from
|
|
38
|
+
view helpers in Hanami, Rails, or any Ruby class.
|
|
39
|
+
- `Marquery::AssetHandler` Rack middleware with multi-root support and
|
|
40
|
+
path-traversal protection (opt-in via `require "marquery/asset_handler"`).
|
|
41
|
+
- `to_h` on `Marquery::Model` and `Marquery::Collection` returning the
|
|
42
|
+
full attribute hash.
|
|
43
|
+
- `Marquery::Error`, `Marquery::EntryNotFound`, `Marquery::AssetNotFound`,
|
|
44
|
+
and `Marquery::ParseError` exception types.
|
|
45
|
+
- Codeberg / Forgejo CI workflow running rspec and rubocop on Ruby 3.4
|
|
46
|
+
and 4.0.
|
|
47
|
+
- README covering quickstart, queries, error handling, configuration,
|
|
48
|
+
custom index models, custom rendering, preprocessing, shared and
|
|
49
|
+
multi-language assets, framework integration for Hanami / any Ruby app
|
|
50
|
+
/ Rails, pagination, and entry serialization.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wout <hi@wout.codes>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# Marquery
|
|
2
|
+
|
|
3
|
+
[](https://codeberg.org/fluck/marquery/actions?workflow=ci.yml)
|
|
4
|
+
[](https://codeberg.org/fluck/marquery/tags)
|
|
5
|
+
|
|
6
|
+
A markdown query engine for Ruby. Load markdown files with YAML frontmatter
|
|
7
|
+
from a conventional directory layout, query them through a chainable API,
|
|
8
|
+
resolve associated assets, and render content to HTML.
|
|
9
|
+
|
|
10
|
+
This is a Ruby companion to the [Crystal marquery
|
|
11
|
+
shard](https://codeberg.org/fluck/marquery.cr). The Crystal version embeds
|
|
12
|
+
everything at compile time via macros; the Ruby version does the equivalent
|
|
13
|
+
work at runtime with memoization and an opt-in eager-load hook for production
|
|
14
|
+
boot.
|
|
15
|
+
|
|
16
|
+
> [!Note]
|
|
17
|
+
> The original repository is hosted at
|
|
18
|
+
> [Codeberg](https://codeberg.org/fluck/marquery). The [GitHub
|
|
19
|
+
> repo](https://github.com/flucksite/marquery) is just a mirror.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add this to your Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "marquery"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then run `bundle install`. Ruby 3.2 or newer is required.
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
### 1. Lay out your content
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
marquery/
|
|
37
|
+
blog_post/
|
|
38
|
+
_index.md
|
|
39
|
+
20260320_first_post.md
|
|
40
|
+
20260320_first_post/
|
|
41
|
+
hero.png
|
|
42
|
+
20260325_second_post.md
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The directory name (`blog_post`) is derived from your query class name.
|
|
46
|
+
Filenames must start with a `YYYYMMDD_` date prefix.
|
|
47
|
+
|
|
48
|
+
### 2. Define a model (optional)
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class Post
|
|
52
|
+
include Marquery::Model
|
|
53
|
+
|
|
54
|
+
attribute :tags, type: :array, default: []
|
|
55
|
+
attribute :author
|
|
56
|
+
attribute :featured, type: :bool, default: false
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If you do not declare a model, `Marquery::Entry` is used as a default. Standard
|
|
61
|
+
fields are always available: `slug`, `title`, `description`, `content`, `date`,
|
|
62
|
+
`active?`, `source`, `assets`.
|
|
63
|
+
|
|
64
|
+
### 3. Define a query
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class PostQuery
|
|
68
|
+
include Marquery::Query
|
|
69
|
+
|
|
70
|
+
model Post
|
|
71
|
+
order_by :date, :desc
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 4. Query and render
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
PostQuery.new.all
|
|
79
|
+
PostQuery.new.find("first-post")
|
|
80
|
+
PostQuery.new.filter(&:active?).sort_by(&:title).first
|
|
81
|
+
PostQuery.index_entry # _index.md as a Marquery::Index
|
|
82
|
+
|
|
83
|
+
post = PostQuery.new.find("first-post")
|
|
84
|
+
post.to_html
|
|
85
|
+
post.asset("hero.png")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Chainable methods return new query instances, so chains are safe to reuse:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
recent = PostQuery.new.filter(&:active?)
|
|
92
|
+
recent.size # unaffected by later chaining
|
|
93
|
+
recent.sort_by(&:title).first # returns the first by title
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Queries
|
|
97
|
+
|
|
98
|
+
Every filter or sort method returns a new query, so chains never mutate the
|
|
99
|
+
original.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
query = PostQuery.new
|
|
103
|
+
|
|
104
|
+
query.all # Array of entries
|
|
105
|
+
query.first # nil if empty
|
|
106
|
+
query.last # nil if empty
|
|
107
|
+
query.size # entry count
|
|
108
|
+
|
|
109
|
+
query.find("first-post") # raises Marquery::EntryNotFound when missing
|
|
110
|
+
query.find_by_slug("first-post") # returns nil when missing
|
|
111
|
+
|
|
112
|
+
query.previous(post) # previous entry, or nil at the start
|
|
113
|
+
query.next(post) # next entry, or nil at the end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Chainable methods, all returning a new query:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
query.filter(&:active?)
|
|
120
|
+
query.reject(&:active?)
|
|
121
|
+
query.sort_by(&:title)
|
|
122
|
+
query.reverse
|
|
123
|
+
query.shuffle
|
|
124
|
+
query.shuffle(random: Random.new(42))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`Marquery::Query` includes `Enumerable`, so `each`, `map`, `count`, `any?`,
|
|
128
|
+
`take`, and friends are available. Those return plain Arrays, matching
|
|
129
|
+
`Enumerable` conventions. Use the chainable methods above when you want to
|
|
130
|
+
keep working with a query.
|
|
131
|
+
|
|
132
|
+
### Error handling
|
|
133
|
+
|
|
134
|
+
Marquery raises typed exceptions that all inherit from `Marquery::Error`:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
begin
|
|
138
|
+
PostQuery.new.find("nonexistent")
|
|
139
|
+
rescue Marquery::EntryNotFound => exception
|
|
140
|
+
exception.message # => "Entry not found: nonexistent"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
post.asset("missing.png")
|
|
145
|
+
rescue Marquery::AssetNotFound => exception
|
|
146
|
+
exception.message # => "Asset not found: missing.png"
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`Marquery::ParseError` is raised at load time when frontmatter or filenames
|
|
151
|
+
cannot be parsed. Catching `Marquery::Error` picks up all three.
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Marquery.configure do |c|
|
|
157
|
+
c.data_dir = "content"
|
|
158
|
+
c.preprocessor = ->(raw, entry) do
|
|
159
|
+
Liquid::Template.parse(raw).render("entry" => entry)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Eager loading in production
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# config/initializers/marquery.rb (Rails)
|
|
168
|
+
Marquery.eager_load!
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This walks every registered query class and parses files upfront so the first
|
|
172
|
+
request does not pay the loading cost.
|
|
173
|
+
|
|
174
|
+
## Custom index model
|
|
175
|
+
|
|
176
|
+
By default the `_index.md` file is parsed into a `Marquery::Index` with
|
|
177
|
+
the standard `title`, `description`, and `content` fields. For extra
|
|
178
|
+
metadata on the index page, define your own type that includes
|
|
179
|
+
`Marquery::Collection` and declare attributes the same way as on a model:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class PostIndex
|
|
183
|
+
include Marquery::Collection
|
|
184
|
+
|
|
185
|
+
attribute :subtitle
|
|
186
|
+
attribute :featured_slugs, type: :array, default: []
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
class PostQuery
|
|
190
|
+
include Marquery::Query
|
|
191
|
+
|
|
192
|
+
index PostIndex
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```markdown
|
|
197
|
+
---
|
|
198
|
+
title: Blog
|
|
199
|
+
subtitle: Thoughts on Ruby and Hanami
|
|
200
|
+
featured_slugs:
|
|
201
|
+
- first-post
|
|
202
|
+
- third-post
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
Welcome to the blog.
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
PostQuery.index_entry.subtitle # => "Thoughts on Ruby and Hanami"
|
|
210
|
+
PostQuery.index_entry.featured_slugs # => ["first-post", "third-post"]
|
|
211
|
+
PostQuery.index_entry.to_html # => "<p>Welcome to the blog.</p>\n"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
If no `_index.md` exists, `index_entry` returns an empty `Marquery::Index`
|
|
215
|
+
(or your custom collection) with default field values.
|
|
216
|
+
|
|
217
|
+
## Custom rendering
|
|
218
|
+
|
|
219
|
+
The default renderer uses
|
|
220
|
+
[commonmarker](https://github.com/gjtorikian/commonmarker) for GitHub-flavored
|
|
221
|
+
markdown. Swap it out per model:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class MyRenderer
|
|
225
|
+
include Marquery::MarkdownToHtml
|
|
226
|
+
|
|
227
|
+
def markdown_to_html(content)
|
|
228
|
+
Kramdown::Document.new(content).to_html
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
class Post
|
|
233
|
+
include Marquery::Model
|
|
234
|
+
renderer MyRenderer
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Preprocessing
|
|
239
|
+
|
|
240
|
+
Two hooks, with per-model winning over global:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# Global default.
|
|
244
|
+
Marquery.configure do |c|
|
|
245
|
+
c.preprocessor = ->(raw, entry) { ... }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Per-model override.
|
|
249
|
+
class Post
|
|
250
|
+
include Marquery::Model
|
|
251
|
+
|
|
252
|
+
def process_content(raw)
|
|
253
|
+
rendered = Liquid::Template.parse(raw).render("entry" => self)
|
|
254
|
+
rewrite_assets(rendered)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
If you override `process_content`, you are in charge of asset rewriting. Call
|
|
260
|
+
`rewrite_assets(content)` to opt back in.
|
|
261
|
+
|
|
262
|
+
## Shared and multi-language assets
|
|
263
|
+
|
|
264
|
+
For multilingual sites, point several query classes at a shared assets
|
|
265
|
+
directory. Each language gets its own content tree; assets live once.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
class Blog::EnQuery
|
|
269
|
+
include Marquery::Query
|
|
270
|
+
dir "blog_en"
|
|
271
|
+
assets_dir "blog_assets"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
class Blog::NlQuery
|
|
275
|
+
include Marquery::Query
|
|
276
|
+
dir "blog_nl"
|
|
277
|
+
assets_dir "blog_assets"
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Both `dir` and `assets_dir` resolve under `Marquery.config.data_dir` (default
|
|
282
|
+
`marquery/`). The example above looks at:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
marquery/blog_en/ # English entries
|
|
286
|
+
marquery/blog_nl/ # Dutch entries
|
|
287
|
+
marquery/blog_assets/ # shared assets
|
|
288
|
+
├── _shared/
|
|
289
|
+
│ └── logo.svg # available on every entry
|
|
290
|
+
├── 20260320/
|
|
291
|
+
│ └── hero.png # available on entries dated 20260320
|
|
292
|
+
└── 20260325/
|
|
293
|
+
└── diagram.svg
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Assets merge in three layers, each overriding the previous one when keys
|
|
297
|
+
collide:
|
|
298
|
+
|
|
299
|
+
1. `_shared/` (available to every entry)
|
|
300
|
+
2. `<YYYYMMDD>/` (available to entries with that date prefix)
|
|
301
|
+
3. The per-entry sibling directory next to the markdown file
|
|
302
|
+
|
|
303
|
+
So a per-entry `banner.png` wins over a date-scoped `banner.png`, which in
|
|
304
|
+
turn wins over a `_shared/banner.png`. This lets you provide sensible
|
|
305
|
+
defaults and override per-entry when needed.
|
|
306
|
+
|
|
307
|
+
## Framework integration
|
|
308
|
+
|
|
309
|
+
Marquery ships two opt-in pieces for integrating with web frameworks:
|
|
310
|
+
|
|
311
|
+
- `Marquery::Helpers` exposes a `markdown(content)` method that accepts a
|
|
312
|
+
String or any `Marquery::Renderable` (model or collection instance).
|
|
313
|
+
- `Marquery::AssetHandler` is Rack middleware that serves files from one or
|
|
314
|
+
more marquery data directories with path-traversal protection.
|
|
315
|
+
|
|
316
|
+
The two snippets below are everything you need. The framework-specific sections
|
|
317
|
+
after them only differ in where you wire them up.
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# Renders a String or a Marquery::Renderable instance to HTML.
|
|
321
|
+
Marquery::Helpers.markdown(post) # => "<h1>...</h1>"
|
|
322
|
+
Marquery::Helpers.markdown("# Hello") # => "<h1>...</h1>"
|
|
323
|
+
Marquery::Helpers.markdown(post, renderer: MyRenderer)
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
require "marquery/asset_handler"
|
|
328
|
+
|
|
329
|
+
# Rack middleware. Pass one or more directories to serve files from.
|
|
330
|
+
# `data_path` and `assets_path` are computed under Marquery.config.data_dir,
|
|
331
|
+
# so they stay in sync if you change the global root.
|
|
332
|
+
use Marquery::AssetHandler, PostQuery.data_path, PostQuery.assets_path
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Hanami
|
|
336
|
+
|
|
337
|
+
Include `Marquery::Helpers` in your slice's view helpers and mount the asset
|
|
338
|
+
handler as middleware. Both Hanami's app config and route-scoped middleware
|
|
339
|
+
work; pick whichever fits your slice layout.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# app/views/helpers.rb
|
|
343
|
+
module MyApp
|
|
344
|
+
module Views
|
|
345
|
+
module Helpers
|
|
346
|
+
include Marquery::Helpers
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
# config/app.rb
|
|
354
|
+
require "marquery/asset_handler"
|
|
355
|
+
require_relative "../app/queries/post_query"
|
|
356
|
+
|
|
357
|
+
module MyApp
|
|
358
|
+
class App < Hanami::App
|
|
359
|
+
config.middleware.use Marquery::AssetHandler, PostQuery.data_path
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
In templates, mark the output as safe HTML with Hanami's `raw` helper:
|
|
365
|
+
|
|
366
|
+
```erb
|
|
367
|
+
<%= raw markdown(post) %>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Any Ruby app
|
|
371
|
+
|
|
372
|
+
`Marquery::Helpers` is a plain module. `extend` it on the object that needs
|
|
373
|
+
the helper, or include it in a class.
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
class PostPresenter
|
|
377
|
+
include Marquery::Helpers
|
|
378
|
+
|
|
379
|
+
def initialize(post)
|
|
380
|
+
@post = post
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def html
|
|
384
|
+
markdown(@post)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
If you serve HTTP through Rack (Sinatra, Roda, plain Rack), mount the asset
|
|
390
|
+
handler in `config.ru`:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
require "marquery/asset_handler"
|
|
394
|
+
require_relative "post_query"
|
|
395
|
+
|
|
396
|
+
use Marquery::AssetHandler, PostQuery.data_path
|
|
397
|
+
run MyApp
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Rails
|
|
401
|
+
|
|
402
|
+
Mix `Marquery::Helpers` into `ApplicationHelper` so `markdown` is available
|
|
403
|
+
across views.
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
# app/helpers/application_helper.rb
|
|
407
|
+
module ApplicationHelper
|
|
408
|
+
include Marquery::Helpers
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
```erb
|
|
413
|
+
<%= raw markdown(@post) %>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
For serving assets, either copy `marquery/` under `public/`, point Propshaft
|
|
417
|
+
at it, or add the Rack middleware:
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
# config/application.rb
|
|
421
|
+
require "marquery/asset_handler"
|
|
422
|
+
|
|
423
|
+
config.middleware.use Marquery::AssetHandler, PostQuery.data_path
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Pagination
|
|
427
|
+
|
|
428
|
+
`query.all` returns a plain Array, so any pagination library that takes an
|
|
429
|
+
Array works.
|
|
430
|
+
|
|
431
|
+
[Pagy](https://github.com/ddnexus/pagy) is the lightest option and works in
|
|
432
|
+
Hanami, Rails, Sinatra, Roda, and plain Rack:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
@pagy, @posts = pagy_array(PostQuery.new.all, items: 10)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
For Rails, [Kaminari](https://github.com/kaminari/kaminari) also paginates
|
|
439
|
+
arrays:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
@posts = Kaminari.paginate_array(PostQuery.new.all)
|
|
443
|
+
.page(params[:page]).per(10)
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
For plain Ruby, slice it yourself:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
pages = PostQuery.new.all.each_slice(10).to_a
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Serializing entries
|
|
453
|
+
|
|
454
|
+
Both `Marquery::Model` and `Marquery::Collection` expose `to_h`, returning a
|
|
455
|
+
plain Hash of standard plus declared attributes. Pipe it through any JSON
|
|
456
|
+
library you like:
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
require "json"
|
|
460
|
+
JSON.generate(post.to_h)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Development
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
bundle install
|
|
467
|
+
bundle exec rspec
|
|
468
|
+
bundle exec rubocop
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## License
|
|
472
|
+
|
|
473
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/mime"
|
|
5
|
+
|
|
6
|
+
module Marquery
|
|
7
|
+
class AssetHandler
|
|
8
|
+
def initialize(app, *directories)
|
|
9
|
+
@app = app
|
|
10
|
+
@roots = directories.flatten.compact.map { resolve_root(_1) }.compact
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
path = env["PATH_INFO"].to_s
|
|
15
|
+
if (resolved = serveable_path(path))
|
|
16
|
+
serve_file(resolved)
|
|
17
|
+
else
|
|
18
|
+
@app.call(env)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolve_root(dir)
|
|
25
|
+
expanded = File.expand_path(dir.to_s)
|
|
26
|
+
return nil unless File.directory?(expanded)
|
|
27
|
+
|
|
28
|
+
File.realpath(expanded)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def serveable_path(request_path)
|
|
32
|
+
return nil if request_path.nil? || request_path.empty?
|
|
33
|
+
|
|
34
|
+
stripped = request_path.sub(%r{\A/}, "")
|
|
35
|
+
return nil if stripped.empty?
|
|
36
|
+
return nil unless File.file?(stripped)
|
|
37
|
+
|
|
38
|
+
real = File.realpath(stripped)
|
|
39
|
+
contained?(real) ? real : nil
|
|
40
|
+
rescue Errno::ENOENT, Errno::ENAMETOOLONG, Errno::EACCES
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def contained?(real_path)
|
|
45
|
+
@roots.any? do |root|
|
|
46
|
+
real_path == root || real_path.start_with?(root + File::SEPARATOR)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def serve_file(path)
|
|
51
|
+
mime = Rack::Mime.mime_type(File.extname(path), "application/octet-stream")
|
|
52
|
+
body = File.binread(path)
|
|
53
|
+
headers = {
|
|
54
|
+
"content-type" => mime,
|
|
55
|
+
"content-length" => body.bytesize.to_s
|
|
56
|
+
}
|
|
57
|
+
[200, headers, [body]]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Marquery
|
|
7
|
+
module Attributable
|
|
8
|
+
VALID_TYPES = %i[string int float bool date time array].freeze
|
|
9
|
+
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
def attribute(name, type: nil, default: nil)
|
|
16
|
+
validate_type!(type) if type
|
|
17
|
+
|
|
18
|
+
name = name.to_sym
|
|
19
|
+
attributes[name] = {type: type, default: deep_freeze(default)}
|
|
20
|
+
attr_reader name
|
|
21
|
+
|
|
22
|
+
alias_method("#{name}?", name) if type == :bool
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def attributes
|
|
26
|
+
@attributes ||= {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_type!(type)
|
|
32
|
+
return if VALID_TYPES.include?(type)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError,
|
|
35
|
+
"Unknown attribute type: #{type.inspect} (expected one of #{VALID_TYPES.inspect})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def deep_freeze(value)
|
|
39
|
+
case value
|
|
40
|
+
when Array then value.map { deep_freeze(_1) }.freeze
|
|
41
|
+
when Hash then value.transform_values { deep_freeze(_1) }.freeze
|
|
42
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def assign_declared_attributes(attrs)
|
|
51
|
+
self.class.attributes.each do |name, config|
|
|
52
|
+
raw = attrs.key?(name) ? attrs[name] : config[:default]
|
|
53
|
+
value = config[:type] && !raw.nil? ? coerce_attribute(raw, config[:type]) : raw
|
|
54
|
+
instance_variable_set(:"@#{name}", value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def coerce_attribute(value, type)
|
|
59
|
+
case type
|
|
60
|
+
when :string then value.to_s
|
|
61
|
+
when :int then Integer(value)
|
|
62
|
+
when :float then Float(value)
|
|
63
|
+
when :bool then value == true || value == "true"
|
|
64
|
+
when :date then value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
65
|
+
when :time then value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
66
|
+
when :array then Array(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|