andromeda_cms 0.1.0-aarch64-linux

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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +276 -0
  6. data/lib/andromeda/assets.rb +128 -0
  7. data/lib/andromeda/check.rb +71 -0
  8. data/lib/andromeda/components/errors.rb +97 -0
  9. data/lib/andromeda/components/import_scanner.rb +72 -0
  10. data/lib/andromeda/components/static_expression.rb +79 -0
  11. data/lib/andromeda/components.rb +348 -0
  12. data/lib/andromeda/configuration.rb +91 -0
  13. data/lib/andromeda/entry.rb +294 -0
  14. data/lib/andromeda/errors.rb +173 -0
  15. data/lib/andromeda/fix.rb +71 -0
  16. data/lib/andromeda/frontmatter.rb +327 -0
  17. data/lib/andromeda/helpers.rb +84 -0
  18. data/lib/andromeda/id.rb +58 -0
  19. data/lib/andromeda/loader.rb +96 -0
  20. data/lib/andromeda/parser.rb +75 -0
  21. data/lib/andromeda/pipeline.rb +296 -0
  22. data/lib/andromeda/railtie.rb +47 -0
  23. data/lib/andromeda/registry.rb +127 -0
  24. data/lib/andromeda/relation.rb +125 -0
  25. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  26. data/lib/andromeda/renderer/errors.rb +52 -0
  27. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  28. data/lib/andromeda/renderer.rb +411 -0
  29. data/lib/andromeda/schema.rb +383 -0
  30. data/lib/andromeda/slugger.rb +68 -0
  31. data/lib/andromeda/store.rb +286 -0
  32. data/lib/andromeda/tasks/andromeda.rake +56 -0
  33. data/lib/andromeda/version.rb +5 -0
  34. data/lib/andromeda_cms/3.2/andromeda_cms.so +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.so +0 -0
  38. data/lib/andromeda_cms.rb +41 -0
  39. data/lib/generators/andromeda/collection/USAGE +25 -0
  40. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  41. data/lib/generators/andromeda/component/USAGE +15 -0
  42. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  43. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  44. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  45. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  46. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  47. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  48. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  49. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  50. data/lib/generators/andromeda/install/USAGE +11 -0
  51. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  52. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  53. metadata +163 -0
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Andromeda
6
+ # The base class users subclass to define a collection:
7
+ #
8
+ # class Content::Post < Andromeda::Entry
9
+ # collection :blog, base: "app/content/blog", pattern: "**/*.{md,mdx}"
10
+ #
11
+ # attribute :title, :string, required: true
12
+ # attribute :pub_date, :date, required: true
13
+ # attribute :draft, :boolean, default: false
14
+ #
15
+ # scope :published, -> { where(draft: false) }
16
+ # end
17
+ #
18
+ # Content::Post.published.order(pub_date: :desc).limit(10)
19
+ # Content::Post.find("hello-world") # => Content::Post
20
+ # Content::Post.find("nope") # => raises Andromeda::EntryNotFound
21
+ #
22
+ # `Entry` only holds source-derived data (`id`/`collection`/`data`/`body`/
23
+ # `file_path`/`digest`) -- it does not render HTML. Rendering is the
24
+ # renderer's job and the store's; this class exists so
25
+ # they have something to render/persist in the first place.
26
+ class Entry
27
+ class << self
28
+ # @return [Symbol, nil] set by `collection`.
29
+ attr_reader :collection_name
30
+ # @return [String, nil] absolute path, set by `collection`.
31
+ attr_reader :base_dir
32
+ # @return [String, nil] a Dir.glob pattern relative to `base_dir`.
33
+ attr_reader :pattern
34
+
35
+ # Declares this class as the entry type for `name`, registering it in
36
+ # Andromeda::Registry so `Andromeda.get_collection`/`get_entry`
37
+ # and `reference` resolution can find it by name.
38
+ #
39
+ # @param name [Symbol, String]
40
+ # @param base [String] absolute, or relative to Rails.root (or the
41
+ # current working directory outside Rails).
42
+ # @param pattern [String] a Dir.glob pattern, relative to `base`.
43
+ def collection(name, base:, pattern: "**/*.{md,mdx}")
44
+ @collection_name = name.to_sym
45
+ @base_dir = resolve_base(base)
46
+ @pattern = pattern
47
+ @entries = nil
48
+ Andromeda::Registry.register(@collection_name, self)
49
+ self
50
+ end
51
+
52
+ # Declares one frontmatter attribute, delegating all type/validation
53
+ # logic to Andromeda::Schema (this class never reimplements it) and
54
+ # additionally defining an instance reader so `post.title` works as
55
+ # a shorthand for `post.data[:title]`.
56
+ #
57
+ # A `:reference` attribute gets a different reader: it resolves the
58
+ # stored `Andromeda::Reference` to the actual target entry (lazily,
59
+ # via the registry) rather than returning the pointer itself --
60
+ # `post.data[:author]` still holds the raw Reference for callers that
61
+ # want it.
62
+ #
63
+ # @see Andromeda::Schema#attribute for the full parameter list.
64
+ def attribute(name, type, **options)
65
+ schema.attribute(name, type, **options)
66
+ attr_name = name.to_sym
67
+
68
+ if type == :reference
69
+ define_method(attr_name) { resolve_reference(attr_name) }
70
+ else
71
+ define_method(attr_name) { data[attr_name] }
72
+ end
73
+
74
+ self
75
+ end
76
+
77
+ # `scope :published, -> { where(draft: false) }`. The block runs via
78
+ # `instance_exec` against an Andromeda::Relation (either `all`, at the
79
+ # class level, or an existing Relation when chained), so it can call
80
+ # `where`/`order`/other scopes unqualified.
81
+ #
82
+ # @param name [Symbol]
83
+ # @param callable [Proc]
84
+ def scope(name, callable)
85
+ name = name.to_sym
86
+ scopes[name] = callable
87
+ define_singleton_method(name) { |*args| all.public_send(name, *args) }
88
+ self
89
+ end
90
+
91
+ # @return [Hash{Symbol => Proc}] public so Andromeda::Relation can look
92
+ # up a scope by name when one is chained off a Relation instance.
93
+ def scopes
94
+ @scopes ||= {}
95
+ end
96
+
97
+ # @return [Andromeda::Schema] this class's own, never inherited/shared
98
+ # -- each subclass declares its own attributes independently.
99
+ def schema
100
+ @schema ||= Andromeda::Schema.new
101
+ end
102
+
103
+ # @return [Andromeda::Relation] every loaded entry.
104
+ def all
105
+ Andromeda::Relation.new(entries, entry_class: self)
106
+ end
107
+
108
+ # @param id [String, Symbol]
109
+ # @return [Andromeda::Entry]
110
+ # @raise [Andromeda::EntryNotFound]
111
+ def find(id)
112
+ target = id.to_s
113
+ entries.find { |entry| entry.id == target } ||
114
+ raise(Andromeda::EntryNotFound.new(collection: collection_name, id: target, candidates: entries.map(&:id)))
115
+ end
116
+
117
+ def find_by(conditions = nil, &block)
118
+ all.find_by(conditions, &block)
119
+ end
120
+
121
+ def where(conditions = nil, &block)
122
+ all.where(conditions, &block)
123
+ end
124
+
125
+ def order(*criteria)
126
+ all.order(*criteria)
127
+ end
128
+
129
+ def limit(count)
130
+ all.limit(count)
131
+ end
132
+
133
+ def first(count = nil)
134
+ all.first(count)
135
+ end
136
+
137
+ def count
138
+ entries.size
139
+ end
140
+
141
+ # The list-view counterpart to `all`: in production
142
+ # this reads only `.andromeda/<collection>/_index.json` (no source
143
+ # file, no parsing), the same data an index page needs (`id`/`data`/
144
+ # `headings`/`digest`/`file_path`) without opening every entry's own
145
+ # JSON. In development it refreshes stale entries first so
146
+ # edits show up without a separate build step. Additive: `all`/
147
+ # `find`/`where`/etc. are unchanged and still read source files
148
+ # directly through Andromeda::Loader.
149
+ #
150
+ # @return [Array<Hash>]
151
+ # @raise [Andromeda::BuildMissing] in production, if nothing has been
152
+ # built for this collection yet.
153
+ def index
154
+ Andromeda::Pipeline.new.collection_index(self)
155
+ end
156
+
157
+ # Drops the in-memory cache, forcing the next query to re-run the
158
+ # Loader. Used by tests that change fixtures mid-example, and is the
159
+ # hook the dev-mode "reconvert on stale mtime" check will call.
160
+ def reload!
161
+ @entries = nil
162
+ end
163
+
164
+ private
165
+
166
+ # Production never touches source files: everything a query
167
+ # needs was validated and written to `_index.json` at build time, so
168
+ # reading it back is both faster and immune to a content file that
169
+ # changed on disk after the build. Development goes to the Loader so
170
+ # edits are visible without a build step.
171
+ def entries
172
+ @entries ||= production? ? entries_from_index : load_entries_from_source
173
+ end
174
+
175
+ def production?
176
+ Andromeda.config.mode == :production
177
+ end
178
+
179
+ def load_entries_from_source
180
+ Andromeda::Loader.new(
181
+ entry_class: self, base: base_dir, pattern: pattern, schema: schema
182
+ ).load
183
+ end
184
+
185
+ # The index carries no body: rendering already happened at build time,
186
+ # and `Entry#html` reads the per-entry JSON rather than the body, so a
187
+ # listing never pays for content it does not show.
188
+ def entries_from_index
189
+ index.map do |summary|
190
+ summary = summary.transform_keys(&:to_sym)
191
+ new(
192
+ id: summary[:id],
193
+ collection: collection_name,
194
+ data: summary[:data],
195
+ body: nil,
196
+ file_path: summary[:file_path],
197
+ digest: summary[:digest]
198
+ )
199
+ end
200
+ end
201
+
202
+ # `base:` is written as a project-relative string in every example
203
+ # (`"app/content/blog"`) so it reads the same in an initializer-free
204
+ # Rails app either way; resolving it here (rather than deferring to
205
+ # the Loader) keeps the Loader itself Rails-agnostic and testable with
206
+ # plain absolute paths.
207
+ def resolve_base(base)
208
+ return base if Pathname.new(base).absolute?
209
+
210
+ File.join(Andromeda.config.project_root, base)
211
+ end
212
+ end
213
+
214
+ # @return [String]
215
+ attr_reader :id
216
+ # @return [Symbol]
217
+ attr_reader :collection
218
+ # @return [Hash] Symbol-keyed validated frontmatter (may also carry
219
+ # String keys for unrecognized frontmatter -- see Schema::Result).
220
+ attr_reader :data
221
+ # @return [String] raw Markdown/MDX body, frontmatter stripped but with
222
+ # original line numbers preserved (Andromeda::Frontmatter#parse).
223
+ attr_reader :body
224
+ # @return [String] absolute path to the source file.
225
+ attr_reader :file_path
226
+ # @return [String] SHA-256 of the whole source file, for cache keys.
227
+ attr_reader :digest
228
+
229
+ def initialize(id:, collection:, data:, body:, file_path:, digest:)
230
+ @id = id
231
+ @collection = collection
232
+ @data = data
233
+ @body = body
234
+ @file_path = file_path
235
+ @digest = digest
236
+ end
237
+
238
+ # The rendered HTML for `body` (MDX components expanded),
239
+ # produced and cached by Andromeda::Pipeline rather than here --
240
+ # this method only decides *when* to ask for it. Memoized on the
241
+ # instance (not globally): a fresh Entry is what a reload/re-Loader-pass
242
+ # produces, carrying today's `digest`, so per-instance memoization can
243
+ # never serve a stale render across a reload the way a class-level or
244
+ # process-wide cache could (see Configuration#mode's dev-mode note).
245
+ #
246
+ # @return [String]
247
+ # @raise [Andromeda::BuildMissing] in production, if this entry has
248
+ # never been built.
249
+ def html
250
+ converted[:html]
251
+ end
252
+
253
+ # @return [Array<Hash>] `{depth:, slug:, text:}` per heading, in
254
+ # document order (Andromeda::Renderer::Result#headings).
255
+ # @raise [Andromeda::BuildMissing] in production, if this entry has
256
+ # never been built.
257
+ def headings
258
+ converted[:headings]
259
+ end
260
+
261
+ # @return [Hash] everything needed to rebuild this entry with `from_h`,
262
+ # so the build step can persist it (`.andromeda/<collection>/<id>.json`) and
263
+ # restore it later without re-parsing/re-validating the source file.
264
+ def to_h
265
+ { id: id, collection: collection, data: data, body: body, file_path: file_path, digest: digest }
266
+ end
267
+
268
+ # @param hash [Hash] as produced by `to_h` (String or Symbol keys).
269
+ # @return [Andromeda::Entry]
270
+ def self.from_h(hash)
271
+ new(**hash.transform_keys(&:to_sym))
272
+ end
273
+
274
+ private
275
+
276
+ def converted
277
+ @converted ||= Andromeda::Pipeline.new.fetch(self)
278
+ end
279
+
280
+ # @param attr_name [Symbol] a `:reference`-typed attribute.
281
+ # @return [Andromeda::Entry, nil]
282
+ # @raise [Andromeda::EntryNotFound] naming both the target collection
283
+ # and id, via Entry.find on the referenced collection.
284
+ def resolve_reference(attr_name)
285
+ reference = data[attr_name]
286
+ return nil if reference.nil?
287
+
288
+ # Only reached when the caller actually asks for `post.author` --
289
+ # deliberately not resolved (or even looked up) at load time, so
290
+ # referencing an entry never forces every other collection to load.
291
+ Andromeda.get_entry(reference.collection, reference.id)
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # Base class so host applications can rescue everything this gem raises.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when Markdown or MDX cannot be parsed. Carries the source location so
8
+ # editors and CI logs can point at the offending line rather than the file.
9
+ class SyntaxError < Error
10
+ attr_reader :path, :line, :column, :raw_message
11
+
12
+ def initialize(message, path: nil, line: nil, column: nil)
13
+ @path = path
14
+ @line = line
15
+ @column = column
16
+ # Kept so the error can be rebuilt once the path is known without having
17
+ # to strip the location back out of the formatted message.
18
+ @raw_message = message
19
+ super(location ? "#{location}: #{message}" : message)
20
+ end
21
+
22
+ # Returns a copy of this error attributed to `path`.
23
+ def at(path)
24
+ self.class.new(raw_message, path: path, line: line, column: column)
25
+ end
26
+
27
+ def location
28
+ return nil unless path || line
29
+
30
+ [path, line, column].compact.join(":")
31
+ end
32
+ end
33
+
34
+ # Raised when the parser itself fails unexpectedly. Sätteri is pre-1.0, so a
35
+ # panic is converted into this instead of aborting the whole Ruby process.
36
+ class ParserError < Error; end
37
+
38
+ # Raised by Schema#validate! when frontmatter fails validation. Carries
39
+ # every Schema::Problem found (Zod-style: report everything in one pass)
40
+ # so a fix-and-rerun cycle can address all of them instead of one at a
41
+ # time, and names the offending file when a path is available so the
42
+ # message is actionable straight out of a build log.
43
+ class ValidationError < Error
44
+ attr_reader :problems, :path
45
+
46
+ def initialize(problems, path: nil)
47
+ @problems = problems
48
+ @path = path
49
+ lines = problems.map { |problem| path ? "#{path}: #{problem.message}" : problem.message }
50
+ super(lines.join("\n"))
51
+ end
52
+ end
53
+
54
+ # Raised by Loader#load when one or more files fail to load (validation
55
+ # failures, syntax errors, duplicate ids). Like ValidationError, this
56
+ # collects everything found across every file in one build rather than
57
+ # stopping at the first bad file, so a fix-and-rerun cycle can address all
58
+ # of them at once instead of playing whack-a-mole one file at a time.
59
+ class LoaderError < Error
60
+ attr_reader :messages
61
+
62
+ def initialize(messages)
63
+ @messages = messages
64
+ super(messages.join("\n"))
65
+ end
66
+ end
67
+
68
+ # Raised by Entry.find / Andromeda.get_entry when no entry has the given
69
+ # id. Lists near-miss ids (by edit distance) when any are close enough to
70
+ # plausibly be a typo -- e.g. `find("hello-wrold")` should hint at
71
+ # `hello-world` rather than just saying "not found".
72
+ class EntryNotFound < Error
73
+ attr_reader :collection, :id, :candidates
74
+
75
+ def initialize(collection:, id:, candidates: [])
76
+ @collection = collection
77
+ @id = id
78
+ @candidates = candidates
79
+
80
+ message = "no #{collection.inspect} entry with id #{id.to_s.inspect}"
81
+ hints = near_misses(id.to_s, candidates)
82
+ message += ". Did you mean #{hints.map(&:inspect).join(" or ")}?" unless hints.empty?
83
+ super(message)
84
+ end
85
+
86
+ private
87
+
88
+ # Only surfaces candidates within a small edit distance -- a long list
89
+ # of unrelated ids would be noise, not a hint.
90
+ def near_misses(id, candidates, max: 3, threshold: 4)
91
+ candidates
92
+ .map { |candidate| [candidate, levenshtein_distance(id, candidate)] }
93
+ .select { |_, distance| distance <= threshold }
94
+ .sort_by { |_, distance| distance }
95
+ .first(max)
96
+ .map(&:first)
97
+ end
98
+
99
+ # Classic Wagner-Fischer DP. No gem dependency for what is, at v0 scale
100
+ # (everything held in memory), a handful of short string comparisons.
101
+ def levenshtein_distance(a, b)
102
+ return b.length if a.empty?
103
+ return a.length if b.empty?
104
+
105
+ costs = (0..b.length).to_a
106
+ a.each_char.with_index(1) do |char_a, i|
107
+ last_diagonal = costs[0]
108
+ costs[0] = i
109
+ b.each_char.with_index(1) do |char_b, j|
110
+ old_cost = costs[j]
111
+ costs[j] = if char_a == char_b
112
+ last_diagonal
113
+ else
114
+ [costs[j] + 1, costs[j - 1] + 1, last_diagonal + 1].min
115
+ end
116
+ last_diagonal = old_cost
117
+ end
118
+ end
119
+ costs.last
120
+ end
121
+ end
122
+
123
+ # Raised by Andromeda::Pipeline#fetch/#collection_index in production mode
124
+ # when a request asks for an entry/collection that
125
+ # `andromeda:build` has never converted -- production never converts on
126
+ # demand, so this is the "you forgot to build" error rather than a
127
+ # transient failure, and the message says exactly what fixes it.
128
+ class BuildMissing < Error
129
+ attr_reader :collection, :id, :file_path
130
+
131
+ def initialize(collection:, id: nil, file_path: nil)
132
+ @collection = collection
133
+ @id = id
134
+ @file_path = file_path
135
+
136
+ target = id ? "#{collection}/#{id}" : collection.to_s
137
+ source = file_path ? " (source: #{file_path})" : ""
138
+ super(
139
+ "no converted output for #{target.inspect}#{source} -- run `bin/rails andromeda:build` to convert " \
140
+ "content into .andromeda/ (this also happens automatically as part of `assets:precompile`)"
141
+ )
142
+ end
143
+ end
144
+
145
+ # Raised by Andromeda::Pipeline#build_collection/.build_all (and
146
+ # #refresh_collection in development) when one or more entries fail to
147
+ # convert -- a schema problem, an unregistered MDX component, a syntax
148
+ # error, and so on. Like Andromeda::LoaderError, every problem found is
149
+ # collected first and raised together, so a build reports everything in
150
+ # one pass instead of a fix-and-rerun cycle one file at a time.
151
+ class BuildError < Error
152
+ attr_reader :messages
153
+
154
+ def initialize(messages)
155
+ @messages = messages
156
+ super(messages.join("\n"))
157
+ end
158
+ end
159
+
160
+ # Raised by Andromeda.get_collection / Andromeda.get_entry when no Entry
161
+ # subclass has registered the given collection name (see
162
+ # Andromeda::Registry) -- most often because its class has not been
163
+ # loaded yet in a development boot where app/ is not eager loaded.
164
+ class UnknownCollection < Error
165
+ def initialize(name)
166
+ super(
167
+ "no collection registered for #{name.inspect} -- does an Andromeda::Entry " \
168
+ "subclass call `collection #{name.inspect}, base: ...`? In development this " \
169
+ "class must be loaded first; see Andromeda::Registry.discover"
170
+ )
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # Rewrites frontmatter keys that are not snake_case, the one migration
5
+ # step content copied from an Astro project reliably needs.
6
+ #
7
+ # The rewrite is textual on purpose: round-tripping through a YAML/TOML
8
+ # emitter would reformat quoting, key order and comments, turning a
9
+ # two-key rename into an unreviewable diff.
10
+ module Fix
11
+ KEY_LINE = /\A(\s*)(["']?)([A-Za-z_][A-Za-z0-9_-]*)\2(\s*[:=]\s*)/
12
+
13
+ module_function
14
+
15
+ # @param entry_classes [Array<Class>] defaults to every registered collection.
16
+ # @param write [Boolean] false previews the changes without touching disk.
17
+ # @return [Array<String>] one line per renamed key, ready to print.
18
+ def run(entry_classes = Andromeda::Pipeline.default_entry_classes, write: true)
19
+ changes = []
20
+
21
+ content_files(entry_classes).each do |path|
22
+ source = File.read(path)
23
+ fixed, renames = fix_source(source)
24
+ next if renames.empty?
25
+
26
+ File.write(path, fixed) if write
27
+ renames.each { |from, to| changes << "#{path}: #{from} -> #{to}" }
28
+ end
29
+
30
+ changes
31
+ end
32
+
33
+ # @return [Array(String, Array<Array(String, String)>)] the rewritten
34
+ # source and the renames applied to it.
35
+ def fix_source(source)
36
+ frontmatter, _format, = Andromeda::Frontmatter.split(source)
37
+ return [source, []] if frontmatter.nil?
38
+
39
+ renames = []
40
+ fixed_frontmatter = frontmatter.lines.map do |line|
41
+ match = KEY_LINE.match(line)
42
+ # Only top-level keys are renamed: an indented key belongs to a
43
+ # nested structure whose shape the schema does not describe.
44
+ next line unless match && match[1].empty?
45
+
46
+ key = match[3]
47
+ snake = snake_case(key)
48
+ next line if snake == key
49
+
50
+ renames << [key, snake]
51
+ line.sub(KEY_LINE) { "#{match[1]}#{match[2]}#{snake}#{match[2]}#{match[4]}" }
52
+ end.join
53
+
54
+ [source.sub(frontmatter, fixed_frontmatter), renames]
55
+ end
56
+
57
+ def snake_case(key)
58
+ key
59
+ .gsub("-", "_")
60
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
61
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
62
+ .downcase
63
+ end
64
+
65
+ def content_files(entry_classes)
66
+ entry_classes.flat_map do |entry_class|
67
+ Dir.glob(File.join(entry_class.base_dir, entry_class.pattern)).sort
68
+ end
69
+ end
70
+ end
71
+ end