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.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attributable"
4
+ require_relative "renderable"
5
+
6
+ module Marquery
7
+ module Collection
8
+ def self.included(base)
9
+ base.include(Marquery::Renderable)
10
+ base.include(Marquery::Attributable)
11
+ end
12
+
13
+ attr_reader :title, :description, :content
14
+
15
+ def initialize(attrs = {})
16
+ attrs = attrs.transform_keys(&:to_sym)
17
+ assign_standard_attributes(attrs)
18
+ assign_declared_attributes(attrs)
19
+ end
20
+
21
+ def to_h
22
+ {title:, description:, content:, assets:}.tap do |base|
23
+ self.class.attributes.each_key do |name|
24
+ base[name] = public_send(name)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def assign_standard_attributes(attrs)
32
+ @title = attrs.fetch(:title, "")
33
+ @description = attrs[:description]
34
+ @content = attrs.fetch(:content, "")
35
+ @assets = attrs.fetch(:assets, {})
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model"
4
+
5
+ module Marquery
6
+ class Entry
7
+ include Marquery::Model
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marquery
4
+ class Error < StandardError; end
5
+
6
+ class EntryNotFound < Error
7
+ def initialize(slug)
8
+ super("Entry not found: #{slug}")
9
+ end
10
+ end
11
+
12
+ class AssetNotFound < Error
13
+ def initialize(name)
14
+ super("Asset not found: #{name}")
15
+ end
16
+ end
17
+
18
+ class ParseError < Error; end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderable"
4
+ require_relative "renderer"
5
+
6
+ module Marquery
7
+ module Helpers
8
+ extend self
9
+
10
+ def markdown(content, renderer: Marquery::Renderer)
11
+ case content
12
+ when nil
13
+ ""
14
+ when Marquery::Renderable
15
+ content.to_html
16
+ when String
17
+ renderer.new.markdown_to_html(content)
18
+ else
19
+ raise(
20
+ ArgumentError,
21
+ "markdown expects a String, nil, or Marquery::Renderable, got #{content.class}"
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "collection"
4
+
5
+ module Marquery
6
+ class Index
7
+ include Marquery::Collection
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marquery
4
+ module MarkdownToHtml
5
+ def markdown_to_html(_content)
6
+ raise NotImplementedError, "#{self.class} must implement #markdown_to_html"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attributable"
4
+ require_relative "renderable"
5
+
6
+ module Marquery
7
+ module Model
8
+ def self.included(base)
9
+ base.include(Marquery::Renderable)
10
+ base.include(Marquery::Attributable)
11
+ end
12
+
13
+ attr_reader :slug, :title, :description, :content, :date, :source
14
+
15
+ def initialize(attrs = {})
16
+ attrs = attrs.transform_keys(&:to_sym)
17
+ assign_standard_attributes(attrs)
18
+ assign_declared_attributes(attrs)
19
+ end
20
+
21
+ def active? = @active
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) && other.slug == slug
25
+ end
26
+ alias_method :eql?, :==
27
+
28
+ def hash = [self.class, slug].hash
29
+
30
+ def to_h
31
+ {
32
+ slug:,
33
+ title:,
34
+ description:,
35
+ content:,
36
+ date:,
37
+ active: active?,
38
+ source:,
39
+ assets:
40
+ }.tap do |base|
41
+ self.class.attributes.each_key do |name|
42
+ base[name] = public_send(name)
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def assign_standard_attributes(attrs)
50
+ @slug = attrs.fetch(:slug)
51
+ @title = attrs.fetch(:title)
52
+ @description = attrs[:description]
53
+ @content = attrs.fetch(:content, "")
54
+ @date = attrs[:date]
55
+ @active = attrs.fetch(:active, true)
56
+ @source = attrs[:source]
57
+ @assets = attrs.fetch(:assets, {})
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marquery
4
+ module Order
5
+ ASC = :asc
6
+ DESC = :desc
7
+
8
+ VALID = [ASC, DESC].freeze
9
+
10
+ def self.validate!(value)
11
+ return value if VALID.include?(value)
12
+
13
+ raise ArgumentError, "Invalid order: #{value.inspect} (expected :asc or :desc)"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "pathname"
5
+ require "time"
6
+ require "yaml"
7
+
8
+ require_relative "error"
9
+ require_relative "order"
10
+
11
+ module Marquery
12
+ class Parser
13
+ DATE_REGEX = /\A(?<date>\d{8})_(?<name>[^.]+)/
14
+ CONTENT_REGEX = /\A(?:---\n(?<frontmatter>.*?)\n---\n)?(?<body>.*)\z/m
15
+ ASSET_EXTENSIONS = %w[.avif .gif .jpeg .jpg .mp3 .mp4 .ogg .pdf .png .svg .webm .webp].freeze
16
+ PERMITTED_YAML_CLASSES = [Date, Time, Symbol].freeze
17
+
18
+ Result = Struct.new(:index, :entries, keyword_init: true)
19
+
20
+ def initialize(dir:, model:, index:, order_by:, assets_dir: nil)
21
+ @dir = Pathname.new(dir.to_s)
22
+ @assets_dir = assets_dir ? Pathname.new(assets_dir.to_s) : nil
23
+ @model = model
24
+ @index = index
25
+ @order_field, @order_direction = order_by
26
+ end
27
+
28
+ def call
29
+ Result.new(index: load_index, entries: sort(load_entries))
30
+ end
31
+
32
+ private
33
+
34
+ def load_index
35
+ file = @dir.join("_index.md")
36
+ return @index.new unless file.file?
37
+
38
+ match = match_content(file.read)
39
+ attrs = {
40
+ content: match[:body].to_s.strip,
41
+ assets: collect_assets(@dir.join("_index"))
42
+ }
43
+ attrs.merge!(parse_frontmatter(match[:frontmatter]))
44
+ @index.new(**attrs)
45
+ end
46
+
47
+ def load_entries
48
+ return [] unless @dir.directory?
49
+
50
+ Pathname
51
+ .glob(@dir.join("*.md"))
52
+ .reject { _1.basename.to_s == "_index.md" }
53
+ .map { load_entry(_1) }
54
+ end
55
+
56
+ def load_entry(path)
57
+ basename = path.basename.to_s
58
+ unless filename_match = basename.match(DATE_REGEX)
59
+ raise Marquery::ParseError, "Invalid filename: #{path}"
60
+ end
61
+
62
+ name = filename_match[:name]
63
+ date_str = filename_match[:date]
64
+ content_match = match_content(path.read)
65
+
66
+ attrs = {
67
+ slug: parameterize(name),
68
+ title: humanize(name),
69
+ date: parse_date(date_str),
70
+ source: path.to_s,
71
+ content: content_match[:body].to_s.strip,
72
+ assets: assets_for(path, date_str)
73
+ }
74
+ attrs.merge!(parse_frontmatter(content_match[:frontmatter]))
75
+ @model.new(**attrs)
76
+ end
77
+
78
+ def assets_for(path, date_str)
79
+ hash = {}
80
+ if @assets_dir
81
+ hash.merge!(collect_assets(@assets_dir.join("_shared")))
82
+ hash.merge!(collect_assets(@assets_dir.join(date_str)))
83
+ end
84
+ hash.merge!(collect_assets(Pathname.new(path.to_s.sub(/\.md\z/, ""))))
85
+ hash
86
+ end
87
+
88
+ def collect_assets(dir)
89
+ return {} unless dir.directory?
90
+
91
+ dir.children.sort.each_with_object({}) do |child, memo|
92
+ basename = child.basename.to_s
93
+ next if basename.start_with?(".")
94
+ next if child.directory?
95
+ next unless ASSET_EXTENSIONS.include?(child.extname.downcase)
96
+
97
+ memo[basename] = child.to_s.delete_prefix("./")
98
+ end
99
+ end
100
+
101
+ def match_content(text)
102
+ match = text.match(CONTENT_REGEX)
103
+ raise Marquery::ParseError, "Unable to parse content" unless match
104
+
105
+ {frontmatter: match[:frontmatter], body: match[:body]}
106
+ end
107
+
108
+ def parse_frontmatter(text)
109
+ return {} if text.nil? || text.strip.empty?
110
+
111
+ parsed = YAML.safe_load(text, permitted_classes: PERMITTED_YAML_CLASSES, aliases: false)
112
+ raise Marquery::ParseError, "Frontmatter must be a YAML mapping" unless parsed.is_a?(Hash)
113
+
114
+ parsed.transform_keys(&:to_sym)
115
+ end
116
+
117
+ def parse_date(date_str)
118
+ Time.strptime(date_str, "%Y%m%d")
119
+ rescue ArgumentError => exception
120
+ raise Marquery::ParseError, "Invalid date prefix #{date_str.inspect}: #{exception.message}"
121
+ end
122
+
123
+ def sort(entries)
124
+ sorted = entries.sort_by { _1.public_send(@order_field) }
125
+ @order_direction == Marquery::Order::DESC ? sorted.reverse : sorted
126
+ end
127
+
128
+ def parameterize(str)
129
+ str
130
+ .tr("_", "-")
131
+ .downcase
132
+ .gsub(/[^a-z0-9-]/, "")
133
+ .squeeze("-")
134
+ .gsub(/\A-|-\z/, "")
135
+ end
136
+
137
+ def humanize(str)
138
+ cleaned = str.tr("_", " ").tr("-", " ").gsub(/\s+/, " ").strip
139
+ return cleaned if cleaned.empty?
140
+
141
+ cleaned[0].upcase + cleaned[1..]
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "entry"
5
+ require_relative "index"
6
+ require_relative "order"
7
+ require_relative "parser"
8
+ require_relative "registry"
9
+
10
+ module Marquery
11
+ module Query
12
+ extend Forwardable
13
+ include Enumerable
14
+
15
+ def self.included(base)
16
+ base.extend(ClassMethods)
17
+ base.instance_variable_set(:@marquery_model, Marquery::Entry)
18
+ base.instance_variable_set(:@marquery_index, Marquery::Index)
19
+ base.instance_variable_set(:@order_field, :date)
20
+ base.instance_variable_set(:@order_direction, Marquery::Order::DESC)
21
+ base.instance_variable_set(:@dir, nil)
22
+ base.instance_variable_set(:@assets_dir, nil)
23
+ base.instance_variable_set(:@marquery_data, nil)
24
+ base.instance_variable_set(:@loaded, false)
25
+ Marquery::Registry.register(base)
26
+ end
27
+
28
+ module ClassMethods
29
+ def model(klass = nil)
30
+ @marquery_model = klass if klass
31
+ @marquery_model
32
+ end
33
+
34
+ def index(klass = nil)
35
+ @marquery_index = klass if klass
36
+ @marquery_index
37
+ end
38
+
39
+ def order_by(field = nil, direction = nil)
40
+ if field
41
+ @order_field = field.to_sym
42
+ @order_direction = Marquery::Order.validate!(direction) if direction
43
+ end
44
+ [@order_field, @order_direction]
45
+ end
46
+
47
+ def dir(path = nil)
48
+ @dir = path.to_s if path
49
+ @dir ||= derive_dir
50
+ end
51
+
52
+ def assets_dir(path = nil)
53
+ @assets_dir = path.to_s unless path.nil?
54
+ @assets_dir
55
+ end
56
+
57
+ def data_path
58
+ File.join(Marquery.config.data_dir, dir)
59
+ end
60
+
61
+ def assets_path
62
+ return nil unless assets_dir
63
+
64
+ File.join(Marquery.config.data_dir, assets_dir)
65
+ end
66
+
67
+ def loaded?
68
+ @loaded
69
+ end
70
+
71
+ def load!
72
+ @marquery_data = Marquery::Parser.new(
73
+ dir: data_path,
74
+ assets_dir: assets_path,
75
+ model: model,
76
+ index: index,
77
+ order_by: order_by
78
+ ).call
79
+ @loaded = true
80
+ self
81
+ end
82
+
83
+ def reload!
84
+ @loaded = false
85
+ @marquery_data = nil
86
+ load!
87
+ end
88
+
89
+ def all_entries
90
+ load! unless loaded?
91
+ @marquery_data.entries
92
+ end
93
+
94
+ def index_entry
95
+ load! unless loaded?
96
+ @marquery_data.index
97
+ end
98
+
99
+ private
100
+
101
+ def derive_dir
102
+ derived = name
103
+ .to_s
104
+ .sub(/Query\z/, "")
105
+ .gsub("::", "")
106
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
107
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
108
+ .downcase
109
+
110
+ return derived unless derived.empty?
111
+
112
+ raise(
113
+ Marquery::Error,
114
+ "Cannot derive directory for #{inspect}; call `dir \"...\"` explicitly"
115
+ )
116
+ end
117
+ end
118
+
119
+ attr_reader :entries
120
+
121
+ def initialize(entries = nil)
122
+ @entries = (entries || self.class.all_entries).freeze
123
+ end
124
+
125
+ def each(&block)
126
+ return enum_for(:each) unless block
127
+
128
+ entries.each(&block)
129
+ self
130
+ end
131
+
132
+ def all = entries
133
+
134
+ def_delegators :entries, :size, :length, :count, :empty?, :first, :last
135
+
136
+ def find(slug)
137
+ find_by_slug(slug) || raise(Marquery::EntryNotFound.new(slug))
138
+ end
139
+
140
+ def find_by_slug(slug)
141
+ entries.find { |entry| entry.slug == slug }
142
+ end
143
+
144
+ def filter(&block)
145
+ return self unless block
146
+
147
+ new_query(entries.select(&block))
148
+ end
149
+ alias_method :select, :filter
150
+
151
+ def reject(&block)
152
+ return self unless block
153
+
154
+ new_query(entries.reject(&block))
155
+ end
156
+
157
+ def sort_by(&block)
158
+ new_query(entries.sort_by(&block))
159
+ end
160
+
161
+ def reverse
162
+ new_query(entries.reverse)
163
+ end
164
+
165
+ def shuffle(random: Random.new)
166
+ new_query(entries.shuffle(random: random))
167
+ end
168
+
169
+ def previous(entry)
170
+ idx = entries.index(entry)
171
+ return nil if idx.nil? || idx.zero?
172
+
173
+ entries[idx - 1]
174
+ end
175
+
176
+ def next(entry)
177
+ idx = entries.index(entry)
178
+ return nil if idx.nil?
179
+
180
+ entries[idx + 1]
181
+ end
182
+
183
+ private
184
+
185
+ def new_query(new_entries)
186
+ self.class.new(new_entries)
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marquery
4
+ module Registry
5
+ @classes = []
6
+ @mutex = Mutex.new
7
+
8
+ class << self
9
+ def classes
10
+ @mutex.synchronize { @classes.dup }
11
+ end
12
+
13
+ def register(klass)
14
+ @mutex.synchronize do
15
+ @classes << klass unless @classes.include?(klass)
16
+ end
17
+ end
18
+
19
+ def reset!
20
+ @mutex.synchronize { @classes.clear }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderer"
4
+
5
+ module Marquery
6
+ ASSET_URI_REGEX = %r{asset:([^\s)"'<>]+)}
7
+
8
+ module Renderable
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ def renderer(klass = nil)
15
+ if klass
16
+ @marquery_renderer = klass
17
+ return klass
18
+ end
19
+
20
+ return @marquery_renderer if defined?(@marquery_renderer) && @marquery_renderer
21
+ return superclass.renderer if superclass.respond_to?(:renderer)
22
+
23
+ Marquery::Renderer
24
+ end
25
+ end
26
+
27
+ def assets = @assets ||= {}
28
+
29
+ def asset(name)
30
+ path = assets[name] || raise(Marquery::AssetNotFound.new(name))
31
+ "/#{path}"
32
+ end
33
+
34
+ def asset?(name)
35
+ path = assets[name]
36
+ path && "/#{path}"
37
+ end
38
+
39
+ def rewrite_assets(raw)
40
+ raw.gsub(Marquery::ASSET_URI_REGEX) { asset(::Regexp.last_match(1)) }
41
+ end
42
+
43
+ def process_content(raw)
44
+ preprocessor = Marquery.config.preprocessor
45
+ return preprocessor.call(raw, self) if preprocessor
46
+
47
+ rewrite_assets(raw)
48
+ end
49
+
50
+ def to_html
51
+ self.class.renderer.new.markdown_to_html(process_content(content))
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "commonmarker"
4
+
5
+ require_relative "markdown_to_html"
6
+
7
+ module Marquery
8
+ class Renderer
9
+ include MarkdownToHtml
10
+
11
+ DEFAULT_OPTIONS = {
12
+ parse: {smart: true},
13
+ render: {unsafe: true, github_pre_lang: true},
14
+ extension: {
15
+ strikethrough: true,
16
+ table: true,
17
+ autolink: true,
18
+ tagfilter: true,
19
+ tasklist: true,
20
+ footnotes: true
21
+ }
22
+ }.freeze
23
+
24
+ def markdown_to_html(content)
25
+ Commonmarker.to_html(content.strip, options: DEFAULT_OPTIONS)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marquery
4
+ VERSION = "0.1.0"
5
+ end
data/lib/marquery.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "marquery/version"
4
+ require_relative "marquery/error"
5
+ require_relative "marquery/order"
6
+ require_relative "marquery/markdown_to_html"
7
+ require_relative "marquery/renderer"
8
+ require_relative "marquery/renderable"
9
+ require_relative "marquery/attributable"
10
+ require_relative "marquery/model"
11
+ require_relative "marquery/entry"
12
+ require_relative "marquery/collection"
13
+ require_relative "marquery/index"
14
+ require_relative "marquery/parser"
15
+ require_relative "marquery/registry"
16
+ require_relative "marquery/query"
17
+ require_relative "marquery/helpers"
18
+
19
+ module Marquery
20
+ class Configuration
21
+ attr_accessor :data_dir, :preprocessor
22
+
23
+ def initialize
24
+ @data_dir = "marquery"
25
+ @preprocessor = nil
26
+ end
27
+ end
28
+
29
+ class << self
30
+ def config
31
+ @config ||= Configuration.new
32
+ end
33
+
34
+ def configure
35
+ yield config
36
+ config
37
+ end
38
+
39
+ def reset_config!
40
+ @config = Configuration.new
41
+ end
42
+
43
+ def eager_load!
44
+ Marquery::Registry.classes.each(&:load!)
45
+ end
46
+ end
47
+ end