andromeda_cms 0.1.0-arm64-darwin
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 +30 -0
- data/CONTRIBUTING.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +276 -0
- data/lib/andromeda/assets.rb +128 -0
- data/lib/andromeda/check.rb +71 -0
- data/lib/andromeda/components/errors.rb +97 -0
- data/lib/andromeda/components/import_scanner.rb +72 -0
- data/lib/andromeda/components/static_expression.rb +79 -0
- data/lib/andromeda/components.rb +348 -0
- data/lib/andromeda/configuration.rb +91 -0
- data/lib/andromeda/entry.rb +294 -0
- data/lib/andromeda/errors.rb +173 -0
- data/lib/andromeda/fix.rb +71 -0
- data/lib/andromeda/frontmatter.rb +327 -0
- data/lib/andromeda/helpers.rb +84 -0
- data/lib/andromeda/id.rb +58 -0
- data/lib/andromeda/loader.rb +96 -0
- data/lib/andromeda/parser.rb +75 -0
- data/lib/andromeda/pipeline.rb +296 -0
- data/lib/andromeda/railtie.rb +47 -0
- data/lib/andromeda/registry.rb +127 -0
- data/lib/andromeda/relation.rb +125 -0
- data/lib/andromeda/renderer/code_highlighter.rb +53 -0
- data/lib/andromeda/renderer/errors.rb +52 -0
- data/lib/andromeda/renderer/literal_expression.rb +201 -0
- data/lib/andromeda/renderer.rb +411 -0
- data/lib/andromeda/schema.rb +383 -0
- data/lib/andromeda/slugger.rb +68 -0
- data/lib/andromeda/store.rb +286 -0
- data/lib/andromeda/tasks/andromeda.rake +56 -0
- data/lib/andromeda/version.rb +5 -0
- data/lib/andromeda_cms/3.2/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.3/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/3.4/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms/4.0/andromeda_cms.bundle +0 -0
- data/lib/andromeda_cms.rb +41 -0
- data/lib/generators/andromeda/collection/USAGE +25 -0
- data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
- data/lib/generators/andromeda/component/USAGE +15 -0
- data/lib/generators/andromeda/component/component_generator.rb +75 -0
- data/lib/generators/andromeda/import_astro/USAGE +21 -0
- data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
- data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
- data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
- data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
- data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
- data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
- data/lib/generators/andromeda/install/USAGE +11 -0
- data/lib/generators/andromeda/install/install_generator.rb +77 -0
- data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
- 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
|