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 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
+ [![CI](https://codeberg.org/fluck/marquery/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/fluck/marquery/actions?workflow=ci.yml)
4
+ [![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcodeberg.org%2Fapi%2Fv1%2Frepos%2Ffluck%2Fmarquery%2Ftags&query=%24%5B0%5D.name&label=version)](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