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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "store"
5
+
6
+ module Andromeda
7
+ # The one place source -> stored-result conversion happens.
8
+ # Both the development request path (convert this one
9
+ # entry, on demand, if it looks stale) and `andromeda:build` (
10
+ # convert every entry, unconditionally) call `Pipeline#convert`/
11
+ # `#build_collection` -- neither reimplements "frontmatter -> tree -> HTML"
12
+ # on its own, so the two can never drift apart: both always run the same
13
+ # conversion process.
14
+ #
15
+ # A Pipeline instance is cheap and stateless beyond its Store/mode, so
16
+ # callers are free to build a fresh one per request/task rather than
17
+ # reaching for a shared singleton.
18
+ class Pipeline
19
+ # @return [Symbol] result of `Pipeline.build_all`/`#build_collection` for
20
+ # one collection: how many entries converted cleanly, and every
21
+ # problem found among the rest (schema violations, missing
22
+ # components, syntax errors) -- collected rather than raised
23
+ # individually, so a build reports everything in one pass (same
24
+ # principle as Andromeda::LoaderError).
25
+ BuildResult = Struct.new(:collection, :converted, :errors, keyword_init: true) do
26
+ def success? = errors.empty?
27
+ end
28
+
29
+ # @param store [Andromeda::Store]
30
+ # @param mode [Symbol, String] `:development` or `:production`.
31
+ # Defaults to `Andromeda.config.mode`, which itself defaults to
32
+ # `:development` -- see Configuration's comment for why that default
33
+ # (rather than `:production`) is the safe one outside a Railtie that
34
+ # has not set it explicitly yet.
35
+ def initialize(store: Andromeda::Store.new, mode: Andromeda.config.mode)
36
+ @store = store
37
+ @mode = mode.to_sym
38
+ end
39
+
40
+ # Converts one already-loaded entry's body into HTML, writes the result
41
+ # to the store, and returns it. Unconditional -- always re-parses and
42
+ # re-renders, even if a stored copy with a matching digest already
43
+ # exists -- because `andromeda:build` needs exactly that ("always
44
+ # produce today's output") and because staleness-checking is `#fetch`'s
45
+ # job, not this one's, so this method has exactly one behaviour to
46
+ # reason about.
47
+ #
48
+ # @param entry [Andromeda::Entry] built by Andromeda::Loader (so
49
+ # `data`/`body`/`digest`/`file_path` all come straight from the
50
+ # source file, not from a previous build).
51
+ # @return [Hash] the stored payload (Symbol-keyed: `id`, `collection`,
52
+ # `data`, `html`, `headings`, `digest`, `file_path`).
53
+ # @raise [Andromeda::SyntaxError, Andromeda::ParserError,
54
+ # Andromeda::Renderer::MissingComponentError] on a bad file; callers
55
+ # that want every problem across a whole collection reported together
56
+ # should go through `#build_collection` instead of calling this
57
+ # directly in a loop.
58
+ def convert(entry)
59
+ mdx = Andromeda::Parser.mdx?(entry.file_path)
60
+ tree = Andromeda::Parser.parse(entry.body, mdx: mdx, path: entry.file_path)
61
+ result = Andromeda::Renderer.new(
62
+ components: components_for(entry, tree),
63
+ image_resolver: image_resolver_for(entry)
64
+ ).render(tree)
65
+
66
+ payload = {
67
+ id: entry.id,
68
+ collection: entry.collection,
69
+ data: publish_data_images(entry),
70
+ html: result.html,
71
+ headings: result.headings,
72
+ digest: entry.digest,
73
+ render_key: render_key,
74
+ file_path: entry.file_path
75
+ }
76
+ @store.write_entry(entry.collection, entry.id, payload)
77
+ payload
78
+ end
79
+
80
+ # The read path an `Andromeda::Entry` instance's `#html`/`#headings`
81
+ # actually call:
82
+ #
83
+ # - production: read-only. A missing stored entry means the deploy-time
84
+ # `andromeda:build` never ran (or this entry is new since it last
85
+ # did) -- raises `Andromeda::BuildMissing` naming the fix, rather than
86
+ # silently converting in a request. Production never runs the
87
+ # conversion process at all.
88
+ # - development: converts on demand when the stored digest does not
89
+ # match the source file's current digest (or nothing is stored yet),
90
+ # otherwise just reads what is already there. No mtime polling/watcher
91
+ # process -- staleness is only ever checked when something asks
92
+ # to read.
93
+ #
94
+ # @param entry [Andromeda::Entry]
95
+ # @return [Hash] same shape as `#convert`'s return value.
96
+ # @raise [Andromeda::BuildMissing] in production, when nothing is built yet.
97
+ def fetch(entry)
98
+ stored = @store.read_entry(entry.collection, entry.id)
99
+
100
+ if production?
101
+ return stored if stored
102
+
103
+ raise Andromeda::BuildMissing.new(collection: entry.collection, id: entry.id, file_path: entry.file_path)
104
+ end
105
+
106
+ return stored if stored && stored[:digest] == entry.digest && stored[:render_key] == render_key
107
+
108
+ convert(entry)
109
+ end
110
+
111
+ # The list-view read path behind a future `Content::Post.index` helper:
112
+ # in production, just reads `_index.json` (never touches source files);
113
+ # in development, does a full staleness-aware pass over the collection
114
+ # first (via `#refresh_collection`) so the index reflects edits/deletes
115
+ # made since the last read, without a watcher process.
116
+ #
117
+ # @param entry_class [Class] an Andromeda::Entry subclass.
118
+ # @return [Array<Hash>] summaries (`id`/`data`/`headings`/`digest`/`file_path`).
119
+ # @raise [Andromeda::BuildMissing] in production, when nothing is built yet.
120
+ def collection_index(entry_class)
121
+ if production?
122
+ index = @store.read_index(entry_class.collection_name)
123
+ return index if index
124
+
125
+ raise Andromeda::BuildMissing.new(collection: entry_class.collection_name)
126
+ end
127
+
128
+ refresh_collection(entry_class)
129
+ @store.read_index(entry_class.collection_name) || []
130
+ end
131
+
132
+ # Dev-mode collection refresh: re-converts only entries whose stored
133
+ # digest is missing/stale (via `#fetch`, so an unchanged file is never
134
+ # re-parsed), then rewrites `_index.json` from exactly the entries found
135
+ # on disk right now -- which is also what prunes a deleted source file's
136
+ # leftover JSON (Store#replace_collection! deletes any `<id>.json` not
137
+ # in that set).
138
+ #
139
+ # @param entry_class [Class]
140
+ # @return [Array<Hash>] every payload just fetched/converted.
141
+ # @raise [Andromeda::BuildError] if any entry fails to convert; a schema
142
+ # problem across the whole collection surfaces as
143
+ # Andromeda::LoaderError instead, straight out of `entry_class.all`.
144
+ def refresh_collection(entry_class)
145
+ errors = []
146
+ payloads = entry_class.all.to_a.filter_map do |entry|
147
+ begin
148
+ fetch(entry)
149
+ rescue Andromeda::Error => e
150
+ errors << "[#{entry_class.collection_name}] #{entry.file_path}: #{e.message}"
151
+ nil
152
+ end
153
+ end
154
+
155
+ @store.replace_collection!(entry_class.collection_name, payloads)
156
+ raise Andromeda::BuildError, errors unless errors.empty?
157
+
158
+ payloads
159
+ end
160
+
161
+ # `andromeda:build`'s per-collection unit of work (called
162
+ # once per registered collection, or via `Pipeline.build_all`): force-
163
+ # converts every entry (unlike `#refresh_collection`, ignoring any
164
+ # existing stored digest -- a build always reflects *today's* renderer/
165
+ # components, not just today's content), collecting every problem
166
+ # instead of stopping at the first bad file, then writes whatever
167
+ # succeeded and prunes anything stale.
168
+ #
169
+ # @param entry_class [Class]
170
+ # @return [BuildResult]
171
+ def build_collection(entry_class)
172
+ entries =
173
+ begin
174
+ entry_class.all.to_a
175
+ rescue Andromeda::LoaderError => e
176
+ return BuildResult.new(
177
+ collection: entry_class.collection_name, converted: 0,
178
+ errors: e.messages.map { |message| "[#{entry_class.collection_name}] #{message}" }
179
+ )
180
+ end
181
+
182
+ errors = []
183
+ payloads = entries.filter_map do |entry|
184
+ begin
185
+ convert(entry)
186
+ rescue Andromeda::Error => e
187
+ errors << "[#{entry_class.collection_name}] #{entry.file_path}: #{e.message}"
188
+ nil
189
+ end
190
+ end
191
+
192
+ @store.replace_collection!(entry_class.collection_name, payloads)
193
+ BuildResult.new(collection: entry_class.collection_name, converted: payloads.size, errors: errors)
194
+ end
195
+
196
+ # Converts every registered collection, reporting every problem found
197
+ # across all of them together instead of
198
+ # aborting at the first bad collection or the first bad file within one.
199
+ #
200
+ # @param entry_classes [Array<Class>] defaults to every collection
201
+ # currently registered in Andromeda::Registry.
202
+ # @param store [Andromeda::Store] shared across every collection, so
203
+ # tests (and a Rake task that wants a non-default build path) can
204
+ # inject one instead of every collection reaching for
205
+ # `Andromeda::Store.new`'s own `Andromeda.config.build_path` default.
206
+ # @return [Array<BuildResult>] one per collection, on success.
207
+ # @raise [Andromeda::BuildError] listing every problem found, across
208
+ # every collection, if any entry failed to convert.
209
+ def self.build_all(entry_classes = default_entry_classes, store: Andromeda::Store.new)
210
+ results = entry_classes.map { |entry_class| new(store: store).build_collection(entry_class) }
211
+ errors = results.flat_map(&:errors)
212
+ raise Andromeda::BuildError, errors unless errors.empty?
213
+
214
+ results
215
+ end
216
+
217
+ def self.default_entry_classes
218
+ Andromeda::Registry.collection_names.map { |name| Andromeda::Registry[name] }
219
+ end
220
+
221
+ private
222
+
223
+ # What the stored HTML depends on besides the entry's own source.
224
+ # Component partials are rendered into the HTML at conversion time, so
225
+ # without this an edited partial would keep serving the old markup until
226
+ # the MDX file itself changed. Partials outside `components_path` (an
227
+ # import pointing elsewhere under app/views) are not tracked; scanning
228
+ # every view on every development request would cost more than it saves.
229
+ def render_key
230
+ partials = Dir.glob(File.join(Andromeda.config.project_root, Andromeda.config.components_path, "**", "*")).sort.filter_map do |file|
231
+ next unless File.file?(file)
232
+
233
+ [file, File.mtime(file).to_r, File.size(file)].join(":")
234
+ end
235
+ Digest::SHA256.hexdigest([Andromeda::VERSION, Andromeda.config.highlight_theme, *partials].join("\n"))
236
+ end
237
+
238
+ def production?
239
+ @mode == :production
240
+ end
241
+
242
+ # Only constructed for `.mdx` sources: a plain Markdown parse (`mdx:
243
+ # false`) never produces an `mdxJsxFlowElement`/`mdxJsxTextElement` node
244
+ # for the Renderer to call this on, so there is nothing to inject for
245
+ # `.md` entries. Resolved lazily (by constant name, not a `require`) so
246
+ # this file loads and works whether or not
247
+ # `Andromeda::Components` has landed yet.
248
+ # `image` attributes are published alongside the ones written in the body,
249
+ # so a hero image declared only in frontmatter is served the same way and
250
+ # a view never has to publish anything while handling a request.
251
+ def publish_data_images(entry)
252
+ base_dir = Andromeda::Registry[entry.collection]&.base_dir || File.dirname(entry.file_path)
253
+
254
+ entry.data.each_value do |value|
255
+ Andromeda::Assets.publish(value.path, content_root: base_dir) if value.is_a?(Andromeda::Image)
256
+ end
257
+ entry.data
258
+ end
259
+
260
+ # Images referenced from a content file are copied into the asset
261
+ # pipeline as they are encountered, so a build only publishes what the
262
+ # content actually uses and a deleted reference stops shipping its file.
263
+ # Absolute and remote URLs are left alone: they are already served by
264
+ # something else.
265
+ def image_resolver_for(entry)
266
+ content_root = File.dirname(entry.file_path)
267
+ base_dir = Andromeda::Registry[entry.collection]&.base_dir || content_root
268
+
269
+ lambda do |url, _node|
270
+ # Absolute URLs point at something already being served -- a file in
271
+ # `public/`, another host, or an inline data URI -- so they are left
272
+ # exactly as the author wrote them.
273
+ next url if url.empty? || url.start_with?("/", "http://", "https://", "data:")
274
+
275
+ source = File.expand_path(url, content_root)
276
+ if File.file?(source)
277
+ logical = Andromeda::Assets.publish(source, content_root: base_dir)
278
+ next Andromeda::Assets.marker_for(logical) if logical
279
+ end
280
+
281
+ # Not a file next to the content: treat it as a logical asset path so
282
+ # images the application already ships (`app/assets/images/logo.png`,
283
+ # written as `logo.png`) resolve through the asset pipeline and get
284
+ # their digest, instead of 404ing in production.
285
+ Andromeda::Assets.marker_for(url)
286
+ end
287
+ end
288
+
289
+ def components_for(entry, tree)
290
+ return nil unless Andromeda::Parser.mdx?(entry.file_path)
291
+ return nil unless defined?(Andromeda::Components)
292
+
293
+ Andromeda::Components.new(tree: tree, path: entry.file_path, view: nil, frontmatter: entry.data)
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "helpers"
5
+
6
+ module Andromeda
7
+ class Railtie < ::Rails::Railtie
8
+ config.andromeda = Andromeda.config
9
+
10
+ # Development converts on demand so edits show up without a build;
11
+ # anywhere else the build is the only writer. Test behaves like
12
+ # development so a suite never has to run the build task first.
13
+ initializer "andromeda.mode" do
14
+ Andromeda.config.mode = Rails.env.local? ? :development : :production
15
+ end
16
+
17
+ # A slug that matches no entry is a missing page, and should look like
18
+ # one to browsers, crawlers and uptime monitors rather than a 500. Set at
19
+ # class level, as Active Record does for RecordNotFound: ActionDispatch
20
+ # copies this table while it initializes, before any initializer of ours.
21
+ if config.respond_to?(:action_dispatch)
22
+ config.action_dispatch.rescue_responses["Andromeda::EntryNotFound"] = :not_found
23
+ end
24
+
25
+ initializer "andromeda.helpers" do
26
+ ActiveSupport.on_load(:action_view) { include Andromeda::Helpers }
27
+ end
28
+
29
+ # Entry classes are ordinary autoloaded constants, so Zeitwerk -- not
30
+ # Kernel#load -- has to be the one that loads them; loading the files
31
+ # directly would create a second copy of each class that Rails' reloader
32
+ # neither tracks nor unloads. Eager-loading the directory on every
33
+ # `to_prepare` also covers development, where nothing would otherwise
34
+ # reference the classes until a request needs one.
35
+ config.to_prepare do
36
+ Andromeda::Registry.clear!
37
+ directory = Rails.root.join(Andromeda.config.entry_class_path)
38
+ next unless directory.directory?
39
+
40
+ Rails.autoloaders.main.eager_load_dir(directory) if Rails.autoloaders.respond_to?(:main)
41
+ end
42
+
43
+ rake_tasks do
44
+ load File.expand_path("tasks/andromeda.rake", __dir__)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # Maps collection name (`:blog`) to the `Andromeda::Entry` subclass that
5
+ # declared it (`Content::Post`), so `Andromeda.get_collection`/`get_entry`
6
+ # and cross-collection `reference` resolution can find a class by name
7
+ # alone.
8
+ #
9
+ # ## How collection classes get found in development
10
+ #
11
+ # In production, Rails eager loads `app/models/`, so every `Content::*`
12
+ # class runs its `collection` macro (and thus registers itself) simply by
13
+ # being loaded -- no discovery step needed. In development, autoloading is
14
+ # lazy: a class that nothing has referenced yet (the exact situation for
15
+ # an STI-style class only ever looked up by a symbol, never a Ruby
16
+ # constant) never runs, so `Registry[:blog]` would come back empty even
17
+ # though `app/models/content/post.rb` exists on disk.
18
+ #
19
+ # `discover` closes that gap by force-loading every `.rb` file under a
20
+ # directory (default `app/models/content`, the conventional content
21
+ # class location), which runs each file's `collection` call and populates
22
+ # this registry as a side effect. It uses `Kernel#load` rather than
23
+ # `require`, because `require` is a no-op the second time it sees the same
24
+ # absolute path -- exactly the case on every code reload after the first
25
+ # boot, and load is required for `discover` to be reload-safe.
26
+ #
27
+ # This module is intentionally Rails-agnostic: it doesn't reference
28
+ # `Rails`, `ActiveSupport::Reloader`, or zeitwerk. The Railtie is
29
+ # expected to call `discover` from a `Rails.application.reloader.to_prepare`
30
+ # block, which already runs once at boot and again after every reload in
31
+ # development -- giving `discover` the "run again after files changed"
32
+ # trigger for free without this file needing to know reloading exists.
33
+ # One caveat left unresolved: `to_prepare` fires after Rails'
34
+ # own autoloader (Zeitwerk) has already unloaded stale constants for
35
+ # *changed* files, but a class we reach via a plain `load` (bypassing
36
+ # Zeitwerk's own autoload hook) is not necessarily one Zeitwerk considers
37
+ # itself responsible for unloading. If that turns out to matter in
38
+ # practice, the Railtie can instead trigger the load by referencing
39
+ # the constant Zeitwerk expects (`Rails.autoloaders.main.eager_load_dir`)
40
+ # rather than calling `Kernel#load` directly -- `discover` below is the
41
+ # dependency-free fallback for that decision, not the final word on it.
42
+ #
43
+ # `clear!` before rebuilding is what makes a second `discover` call safe:
44
+ # without it, a renamed or deleted entry class would linger in the
45
+ # registry as a zombie pointing at a stale class object whose constant no
46
+ # longer resolves to it.
47
+ module Registry
48
+ DEFAULT_DIRECTORY = "app/models/content"
49
+
50
+ class << self
51
+ # @param name [Symbol, String]
52
+ # @param entry_class [Class] an Andromeda::Entry subclass.
53
+ def register(name, entry_class)
54
+ entries[name.to_sym] = entry_class
55
+ end
56
+
57
+ # @return [Class, nil]
58
+ def [](name)
59
+ entries[name.to_sym]
60
+ end
61
+
62
+ # @return [Array<Symbol>]
63
+ def collection_names
64
+ entries.keys
65
+ end
66
+
67
+ # Wipes every registration. Called automatically at the start of
68
+ # `discover`; exposed on its own for tests that need a clean slate.
69
+ def clear!
70
+ @entries = {}
71
+ end
72
+
73
+ # Force-loads every `.rb` file under `dir`, registering whatever
74
+ # `collection` calls they contain. See the module comment for why
75
+ # this clears the registry first and uses `load` rather than
76
+ # `require`.
77
+ #
78
+ # @param dir [String] absolute path to a directory of entry classes.
79
+ def discover(dir = default_directory)
80
+ clear!
81
+ Dir.glob(File.join(dir, "**", "*.rb")).sort.each { |file| load file }
82
+ self
83
+ end
84
+
85
+ # @return [String] `app/models/content` under Rails.root when Rails is
86
+ # loaded, or under the current working directory otherwise (kept
87
+ # Rails-agnostic so plain-Ruby usage and tests don't need Rails).
88
+ def default_directory
89
+ File.join(Andromeda.config.project_root, DEFAULT_DIRECTORY)
90
+ end
91
+
92
+ private
93
+
94
+ def entries
95
+ @entries ||= {}
96
+ end
97
+ end
98
+ end
99
+
100
+ class << self
101
+ # Astro's `getCollection("blog")`, returning every entry.
102
+ #
103
+ # @param name [Symbol, String]
104
+ # @return [Andromeda::Relation]
105
+ # @raise [Andromeda::UnknownCollection]
106
+ def get_collection(name)
107
+ entry_class_for(name).all
108
+ end
109
+
110
+ # Astro's `getEntry("blog", id)`.
111
+ #
112
+ # @param name [Symbol, String]
113
+ # @param id [String]
114
+ # @return [Andromeda::Entry]
115
+ # @raise [Andromeda::UnknownCollection]
116
+ # @raise [Andromeda::EntryNotFound]
117
+ def get_entry(name, id)
118
+ entry_class_for(name).find(id)
119
+ end
120
+
121
+ private
122
+
123
+ def entry_class_for(name)
124
+ Registry[name] || raise(Andromeda::UnknownCollection, name.to_sym)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ # A small, chainable wrapper around an Array of loaded entries.
5
+ # v0 keeps everything in memory, so there is no query to build up and
6
+ # defer -- every method here just filters/sorts the Array it already has.
7
+ # Deliberately not lazy/Enumerator-based: at v0 scale that would only add
8
+ # indirection without saving any real work.
9
+ class Relation
10
+ include Enumerable
11
+
12
+ # @return [Class, nil] the Andromeda::Entry subclass this relation was
13
+ # built from, so `scope` calls (defined on the class) can be looked up
14
+ # and re-run when chained off a Relation instead of the class itself.
15
+ attr_reader :entry_class
16
+
17
+ # @param records [Array<Andromeda::Entry>]
18
+ # @param entry_class [Class, nil]
19
+ def initialize(records, entry_class: nil)
20
+ @records = records
21
+ @entry_class = entry_class
22
+ end
23
+
24
+ def each
25
+ return enum_for(:each) unless block_given?
26
+
27
+ @records.each { |record| yield record }
28
+ end
29
+
30
+ # @return [Array<Andromeda::Entry>] a defensive copy.
31
+ def to_a
32
+ @records.dup
33
+ end
34
+
35
+ # `where(draft: false)` (equality on `data`) or `where { |e| ... }`
36
+ # (arbitrary block) -- both forms Astro's own `getCollection(name,
37
+ # filter)` supports as one argument, split here into two Ruby-idiomatic
38
+ # entry points instead.
39
+ #
40
+ # @param conditions [Hash, nil] Symbol/String keys, matched against
41
+ # `entry.data`.
42
+ def where(conditions = nil, &block)
43
+ filtered =
44
+ if block
45
+ @records.select(&block)
46
+ elsif conditions
47
+ @records.select { |record| conditions.all? { |key, value| record.data[key.to_sym] == value } }
48
+ else
49
+ @records
50
+ end
51
+
52
+ self.class.new(filtered, entry_class: entry_class)
53
+ end
54
+
55
+ # @return [Andromeda::Entry, nil]
56
+ def find_by(conditions = nil, &block)
57
+ where(conditions, &block).first
58
+ end
59
+
60
+ # `order(:pub_date)` (ascending) or `order(pub_date: :desc)`. Multiple
61
+ # criteria apply left to right, like SQL's `ORDER BY a, b`.
62
+ def order(*criteria)
63
+ keys = criteria.flat_map { |criterion| criterion.is_a?(Hash) ? criterion.to_a : [[criterion, :asc]] }
64
+
65
+ # Array#sort is not stable, but JavaScript's is, and an Astro listing
66
+ # sorted by date keeps same-day posts in their original order. The
67
+ # original position breaks every remaining tie so the order is the
68
+ # same here, and the same on every run.
69
+ sorted = @records.each_with_index.sort do |(a, a_index), (b, b_index)|
70
+ compare_by(keys, a, b).nonzero? || a_index <=> b_index
71
+ end
72
+
73
+ self.class.new(sorted.map(&:first), entry_class: entry_class)
74
+ end
75
+
76
+ # @return [Andromeda::Relation]
77
+ def limit(count)
78
+ self.class.new(@records.first(count), entry_class: entry_class)
79
+ end
80
+
81
+ # @return [Andromeda::Entry, Array<Andromeda::Entry>, nil]
82
+ def first(count = nil)
83
+ count ? @records.first(count) : @records.first
84
+ end
85
+
86
+ def count
87
+ @records.size
88
+ end
89
+
90
+ def size
91
+ @records.size
92
+ end
93
+ alias length size
94
+
95
+ def empty?
96
+ @records.empty?
97
+ end
98
+
99
+ # Lets a scope declared on `entry_class` (via `Entry.scope`) be called
100
+ # directly on a Relation, so `Post.published.recent` chains the same way
101
+ # `Post.published` does at the class level.
102
+ def method_missing(name, *args, &block)
103
+ scope = entry_class&.scopes&.[](name)
104
+ return instance_exec(*args, &scope) if scope
105
+
106
+ super
107
+ end
108
+
109
+ def respond_to_missing?(name, include_private = false)
110
+ !!entry_class&.scopes&.key?(name) || super
111
+ end
112
+
113
+ private
114
+
115
+ # The first criterion on which `a` and `b` differ decides; 0 if none does.
116
+ def compare_by(keys, a, b)
117
+ keys.each do |attribute, direction|
118
+ # Incomparable values (one side nil) count as a tie, not an error.
119
+ comparison = (a.data[attribute.to_sym] <=> b.data[attribute.to_sym]) || 0
120
+ return direction == :desc ? -comparison : comparison unless comparison.zero?
121
+ end
122
+ 0
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module Andromeda
6
+ class Renderer
7
+ # Stands in for Shiki: matches the *markup shape*
8
+ # Astro/Shiki produce for fenced code blocks -- `<pre class="astro-code
9
+ # <theme>" style="..." data-language="...">` -- so existing CSS written
10
+ # against Astro's default output keeps working, while the actual colours
11
+ # come from Rouge instead of Shiki (a byte-identical palette is not the
12
+ # goal -- displaying the code correctly is, not matching Shiki exactly).
13
+ module CodeHighlighter
14
+ # `github.dark` is Rouge's built-in theme closest to Shiki's default
15
+ # `github-dark` (same source palette family: GitHub's dark code theme),
16
+ # and it keeps the CSS class name Astro emits (`astro-code github-dark`)
17
+ # meaningful rather than mismatched with the actual colours.
18
+ THEME_NAME = "github-dark"
19
+ THEME = Rouge::Theme.find("github.dark").new
20
+ FORMATTER = Rouge::Formatters::HTMLInline.new(THEME)
21
+ BACKGROUND = THEME.palette[:bgDefault]
22
+ FOREGROUND = THEME.palette[:fgDefault]
23
+
24
+ module_function
25
+
26
+ # @param code [String] the fenced code block's body (already the raw
27
+ # text -- mdast `code` nodes store it unescaped).
28
+ # @param lang [String, nil] the fence's language tag, if any.
29
+ # @return [String] a full `<pre>...</pre>` element.
30
+ def render(code, lang)
31
+ lexer = lookup_lexer(lang)
32
+ body = lexer ? FORMATTER.format(lexer.lex(code)) : Renderer.escape_html(code)
33
+
34
+ # Shiki/Astro emit `data-language` verbatim from the fence even when
35
+ # the language is not one Shiki recognizes (it would raise instead);
36
+ # Rouge is more permissive about *finding* a lexer but we still fall
37
+ # back to plain text for anything it can't find, per this step's
38
+ # brief ("falls back to plain text without raising") -- so an
39
+ # unrecognized `lang` keeps its `data-language` label for styling
40
+ # purposes even though no syntax highlighting was applied.
41
+ language_attr = lang && !lang.empty? ? %( data-language="#{Renderer.escape_attr(lang)}") : ""
42
+
43
+ %(<pre class="astro-code #{THEME_NAME}" style="background-color:#{BACKGROUND};color:#{FOREGROUND};overflow-x:auto;"#{language_attr}><code>#{body}</code></pre>)
44
+ end
45
+
46
+ def lookup_lexer(lang)
47
+ return nil if lang.nil? || lang.empty?
48
+
49
+ Rouge::Lexer.find(lang.downcase)
50
+ end
51
+ end
52
+ end
53
+ end