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,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "date"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Andromeda
|
|
10
|
+
# Reads and writes the `.andromeda/` build output: one JSON
|
|
11
|
+
# file per entry at `<build_path>/<collection>/<id>.json`, plus one
|
|
12
|
+
# `<build_path>/<collection>/_index.json` per collection holding just
|
|
13
|
+
# enough of every entry (`id`/`data`/`headings`/`digest`/`file_path`) to
|
|
14
|
+
# render a listing page without opening every entry's own file.
|
|
15
|
+
#
|
|
16
|
+
# This class only knows how to read/write that layout safely -- it has no
|
|
17
|
+
# opinion on *when* to convert or re-convert a source file. That decision
|
|
18
|
+
# (dev's mtime/digest check vs. production's read-only stance) is
|
|
19
|
+
# Andromeda::Pipeline's job; Store is the dumb, well-tested disk layer
|
|
20
|
+
# underneath it, deliberately kept ignorant of Parser/Renderer so it can be
|
|
21
|
+
# tested with plain Hashes instead of real content files.
|
|
22
|
+
class Store
|
|
23
|
+
# @param build_path [String] absolute, or relative to Rails.root (or the
|
|
24
|
+
# current working directory outside Rails) -- same convention
|
|
25
|
+
# `Andromeda::Entry.collection`'s `base:` uses for `resolve_base`.
|
|
26
|
+
def initialize(build_path: Andromeda.config.build_path)
|
|
27
|
+
@build_path = resolve_build_path(build_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String] absolute path to the build root.
|
|
31
|
+
attr_reader :build_path
|
|
32
|
+
|
|
33
|
+
# @param collection [Symbol, String]
|
|
34
|
+
# @param id [String]
|
|
35
|
+
# @return [Hash, nil] Symbol-keyed payload (see Pipeline#convert for the
|
|
36
|
+
# shape), or nil if no file exists yet for this id.
|
|
37
|
+
# Used by `andromeda:clobber` (and by `assets:clobber`, which it hooks
|
|
38
|
+
# into) so a stale build can be thrown away wholesale.
|
|
39
|
+
def clobber!
|
|
40
|
+
FileUtils.rm_rf(@build_path)
|
|
41
|
+
@build_path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_entry(collection, id)
|
|
45
|
+
path = entry_path(collection, id)
|
|
46
|
+
return nil unless File.file?(path)
|
|
47
|
+
|
|
48
|
+
decode_entry(read_json(path))
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
# A reader can only ever observe a *complete* file (writes go through
|
|
51
|
+
# `atomic_write`'s write-then-rename), so a parse failure here means
|
|
52
|
+
# something outside this gem touched the file -- treat it the same as
|
|
53
|
+
# "not built yet" rather than raising out of a request.
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param collection [Symbol, String]
|
|
58
|
+
# @return [Array<Hash>, nil] the collection's `_index.json` contents, or
|
|
59
|
+
# nil if it has never been written.
|
|
60
|
+
def read_index(collection)
|
|
61
|
+
path = index_path(collection)
|
|
62
|
+
return nil unless File.file?(path)
|
|
63
|
+
|
|
64
|
+
array = read_json(path)
|
|
65
|
+
array.map { |summary| decode_data_field(summary) }
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Writes one entry's JSON and folds its summary into the collection's
|
|
71
|
+
# `_index.json`, without touching any other entry already in that index.
|
|
72
|
+
# Used by Pipeline's dev-mode "reconvert this one stale file" path, where
|
|
73
|
+
# scanning (let alone rewriting) the whole collection would be wasteful.
|
|
74
|
+
#
|
|
75
|
+
# @param payload [Hash] Symbol-keyed, as built by Pipeline#convert.
|
|
76
|
+
# @return [Hash] `payload`, unchanged, for chaining.
|
|
77
|
+
def write_entry(collection, id, payload)
|
|
78
|
+
atomic_write(entry_path(collection, id), dump_json(encode_entry(payload)))
|
|
79
|
+
merge_index_entry(collection, payload)
|
|
80
|
+
payload
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Overwrites an entire collection at once: every payload's own file, the
|
|
84
|
+
# `_index.json` built from exactly this set, and -- because this is the
|
|
85
|
+
# only place that ever sees "every currently-valid id" for a collection
|
|
86
|
+
# in one place -- deletes any leftover `<id>.json` that does not belong
|
|
87
|
+
# to one of `payloads` (a source file deleted since the last build,
|
|
88
|
+
# so its stale JSON is cleaned up).
|
|
89
|
+
#
|
|
90
|
+
# @param payloads [Array<Hash>]
|
|
91
|
+
# @return [Array<Hash>] `payloads`, unchanged.
|
|
92
|
+
def replace_collection!(collection, payloads)
|
|
93
|
+
dir = collection_dir(collection)
|
|
94
|
+
keep = payloads.to_h { |payload| [entry_path(collection, payload.fetch(:id)), true] }
|
|
95
|
+
|
|
96
|
+
with_index_lock(collection) do
|
|
97
|
+
prune_orphans(dir, collection, keep)
|
|
98
|
+
payloads.each { |payload| atomic_write(entry_path(collection, payload.fetch(:id)), dump_json(encode_entry(payload))) }
|
|
99
|
+
atomic_write(index_path(collection), dump_json(payloads.map { |payload| index_summary(payload) }))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
payloads
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param collection [Symbol, String]
|
|
106
|
+
# @param id [String]
|
|
107
|
+
# @return [String] absolute path, guaranteed to be inside `build_path`.
|
|
108
|
+
# @raise [Andromeda::Error] if `id` (e.g. a hand-written frontmatter
|
|
109
|
+
# `slug:` -- Id.generate lets that win verbatim, unslugified) tries to
|
|
110
|
+
# escape the collection's directory via `..` segments.
|
|
111
|
+
def entry_path(collection, id)
|
|
112
|
+
dir = collection_dir(collection)
|
|
113
|
+
path = File.expand_path(File.join(dir, "#{id}.json"))
|
|
114
|
+
guard_within!(path, dir, id)
|
|
115
|
+
path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param collection [Symbol, String]
|
|
119
|
+
# @return [String] absolute path to `<build_path>/<collection>/_index.json`.
|
|
120
|
+
def index_path(collection)
|
|
121
|
+
File.join(collection_dir(collection), "_index.json")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def resolve_build_path(build_path)
|
|
127
|
+
return File.expand_path(build_path) if Pathname.new(build_path).absolute?
|
|
128
|
+
|
|
129
|
+
File.expand_path(File.join(Andromeda.config.project_root, build_path))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def collection_dir(collection)
|
|
133
|
+
dir = File.expand_path(File.join(@build_path, collection.to_s))
|
|
134
|
+
guard_within!(dir, @build_path, collection)
|
|
135
|
+
dir
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Shared by both `collection_dir` (guards the collection name) and
|
|
139
|
+
# `entry_path` (guards the id): `path` must resolve to somewhere at or
|
|
140
|
+
# under `root`, checked via `expand_path` (which collapses `..`) rather
|
|
141
|
+
# than a string match on the raw value, so `id: "../../etc/passwd"`
|
|
142
|
+
# cannot write outside `build_path` no matter how it is spelled.
|
|
143
|
+
def guard_within!(path, root, offender)
|
|
144
|
+
root_with_separator = File.expand_path(root) + File::SEPARATOR
|
|
145
|
+
return if path == File.expand_path(root) || path.start_with?(root_with_separator)
|
|
146
|
+
|
|
147
|
+
raise Andromeda::Error, "#{offender.inspect} escapes the build directory (#{root})"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def prune_orphans(dir, collection, keep)
|
|
151
|
+
index = File.expand_path(index_path(collection))
|
|
152
|
+
Dir.glob(File.join(dir, "**", "*.json")).each do |file|
|
|
153
|
+
expanded = File.expand_path(file)
|
|
154
|
+
next if expanded == index
|
|
155
|
+
next if keep[expanded]
|
|
156
|
+
|
|
157
|
+
File.delete(expanded)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def merge_index_entry(collection, payload)
|
|
162
|
+
with_index_lock(collection) do
|
|
163
|
+
existing = read_json_array(index_path(collection))
|
|
164
|
+
id = payload.fetch(:id)
|
|
165
|
+
existing.reject! { |summary| summary[:id].to_s == id.to_s }
|
|
166
|
+
existing << index_summary(payload)
|
|
167
|
+
atomic_write(index_path(collection), dump_json(existing))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Development converts on demand from whichever Puma thread or worker
|
|
172
|
+
# serves the request, so two entries of one collection can be written at
|
|
173
|
+
# the same moment. Each rewrites the whole index from what it read, and
|
|
174
|
+
# without this lock the later rename silently drops the earlier entry.
|
|
175
|
+
# `atomic_write` alone cannot help: it keeps readers from seeing a torn
|
|
176
|
+
# file, not writers from overwriting each other. A file lock rather than
|
|
177
|
+
# a Mutex, because the writers may be separate processes.
|
|
178
|
+
def with_index_lock(collection)
|
|
179
|
+
dir = collection_dir(collection)
|
|
180
|
+
FileUtils.mkdir_p(dir)
|
|
181
|
+
File.open(File.join(dir, ".index.lock"), File::RDWR | File::CREAT) do |lock|
|
|
182
|
+
lock.flock(File::LOCK_EX)
|
|
183
|
+
yield
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# `data` is encoded the same way `encode_entry` encodes it for the
|
|
188
|
+
# entry's own file -- the index is read back through `decode_data_field`
|
|
189
|
+
# below, and both sides need to agree on the on-disk (tagged-Hash)
|
|
190
|
+
# representation for a `Date`/`Reference`/`Image` value to round-trip.
|
|
191
|
+
def index_summary(payload)
|
|
192
|
+
{ id: payload.fetch(:id), data: self.class.encode(payload.fetch(:data)), headings: payload.fetch(:headings),
|
|
193
|
+
digest: payload.fetch(:digest), file_path: payload.fetch(:file_path) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_json_array(path)
|
|
197
|
+
return [] unless File.file?(path)
|
|
198
|
+
|
|
199
|
+
read_json(path)
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
[]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def read_json(path)
|
|
205
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def dump_json(object)
|
|
209
|
+
JSON.pretty_generate(object)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Never writes `path` directly: the file is built up-front in a sibling
|
|
213
|
+
# temp file and only `File.rename`d into place once it is complete, so a
|
|
214
|
+
# concurrent reader (another request, or a build process crashing
|
|
215
|
+
# mid-write) can never observe a half-written file at `path` -- it either
|
|
216
|
+
# sees the previous complete version or the new complete version, never
|
|
217
|
+
# something in between.
|
|
218
|
+
def atomic_write(path, content)
|
|
219
|
+
dir = File.dirname(path)
|
|
220
|
+
FileUtils.mkdir_p(dir)
|
|
221
|
+
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}-#{Thread.current.object_id}-#{rand(1_000_000)}.tmp")
|
|
222
|
+
File.write(tmp, content)
|
|
223
|
+
File.rename(tmp, path)
|
|
224
|
+
rescue StandardError
|
|
225
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
226
|
+
raise
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def encode_entry(payload)
|
|
230
|
+
payload.merge(collection: payload[:collection].to_s, data: self.class.encode(payload[:data]))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def decode_entry(payload)
|
|
234
|
+
payload.merge(collection: payload[:collection]&.to_sym, data: self.class.decode(payload[:data]))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def decode_data_field(payload)
|
|
238
|
+
payload.merge(data: self.class.decode(payload[:data]))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
class << self
|
|
242
|
+
# Frontmatter `data` can hold types JSON has no native representation
|
|
243
|
+
# for (`Date`, `Andromeda::Reference`, `Andromeda::Image`) --
|
|
244
|
+
# each is written as a small tagged Hash (`{__type:, ...}`) so `decode`
|
|
245
|
+
# can rebuild the exact same Ruby object on the way back out, keeping
|
|
246
|
+
# the store's round trip (`convert -> write -> read`) lossless for
|
|
247
|
+
# every type `Andromeda::Schema` can produce.
|
|
248
|
+
def encode(value)
|
|
249
|
+
case value
|
|
250
|
+
when Andromeda::Reference then { __type: :reference, collection: value.collection, id: value.id }
|
|
251
|
+
when Andromeda::Image then { __type: :image, path: value.path, relative_path: value.relative_path, asset: value.asset }
|
|
252
|
+
when DateTime then { __type: :datetime, value: value.iso8601 }
|
|
253
|
+
when Time then { __type: :time, value: value.iso8601 }
|
|
254
|
+
when Date then { __type: :date, value: value.iso8601 }
|
|
255
|
+
when Array then value.map { |element| encode(element) }
|
|
256
|
+
when Hash then value.transform_values { |element| encode(element) }
|
|
257
|
+
else value
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def decode(value)
|
|
262
|
+
case value
|
|
263
|
+
when Hash
|
|
264
|
+
value.key?(:__type) ? decode_tagged(value) : value.transform_values { |element| decode(element) }
|
|
265
|
+
when Array
|
|
266
|
+
value.map { |element| decode(element) }
|
|
267
|
+
else
|
|
268
|
+
value
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def decode_tagged(value)
|
|
275
|
+
case value[:__type].to_s
|
|
276
|
+
when "reference" then Andromeda::Reference.new(collection: value[:collection].to_sym, id: value[:id])
|
|
277
|
+
when "image" then Andromeda::Image.new(path: value[:path], relative_path: value[:relative_path], asset: value[:asset])
|
|
278
|
+
when "datetime" then DateTime.iso8601(value[:value])
|
|
279
|
+
when "time" then Time.iso8601(value[:value])
|
|
280
|
+
when "date" then Date.iso8601(value[:value])
|
|
281
|
+
else value
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :andromeda do
|
|
4
|
+
desc "Convert every content file into .andromeda/"
|
|
5
|
+
task build: :environment do
|
|
6
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
results = Andromeda::Pipeline.build_all
|
|
10
|
+
rescue Andromeda::BuildError => e
|
|
11
|
+
# Failing the task (rather than warning) is what keeps a broken deploy
|
|
12
|
+
# from shipping content that silently lost a page.
|
|
13
|
+
warn e.message
|
|
14
|
+
abort "andromeda:build failed"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
entries = results.sum(&:converted)
|
|
18
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
19
|
+
puts "andromeda:build converted #{entries} entries in #{results.size} collection(s) (#{elapsed}ms)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "Remove converted content"
|
|
23
|
+
task clobber: :environment do
|
|
24
|
+
Andromeda::Store.new.clobber!
|
|
25
|
+
puts "andromeda:clobber removed #{Andromeda.config.build_path}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Report content problems without writing anything (for CI)"
|
|
29
|
+
task check: :environment do
|
|
30
|
+
problems = Andromeda::Check.run
|
|
31
|
+
|
|
32
|
+
if problems.empty?
|
|
33
|
+
puts "andromeda:check found no problems"
|
|
34
|
+
else
|
|
35
|
+
problems.each { |problem| warn problem }
|
|
36
|
+
abort "andromeda:check found #{problems.size} problem(s)"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Rewrite non-snake_case frontmatter keys in place"
|
|
41
|
+
task fix: :environment do
|
|
42
|
+
changes = Andromeda::Fix.run
|
|
43
|
+
|
|
44
|
+
if changes.empty?
|
|
45
|
+
puts "andromeda:fix found nothing to change"
|
|
46
|
+
else
|
|
47
|
+
changes.each { |change| puts change }
|
|
48
|
+
puts "andromeda:fix updated #{changes.size} file(s)"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Mirrors how tailwindcss-rails attaches itself, so a deploy that already runs
|
|
54
|
+
# assets:precompile (every Rails 8 Dockerfile does) needs no extra step.
|
|
55
|
+
Rake::Task["assets:precompile"].enhance(["andromeda:build"]) if Rake::Task.task_defined?("assets:precompile")
|
|
56
|
+
Rake::Task["assets:clobber"].enhance(["andromeda:clobber"]) if Rake::Task.task_defined?("assets:clobber")
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "andromeda/version"
|
|
4
|
+
require_relative "andromeda/errors"
|
|
5
|
+
require_relative "andromeda/configuration"
|
|
6
|
+
require_relative "andromeda/assets"
|
|
7
|
+
require_relative "andromeda/helpers"
|
|
8
|
+
require_relative "andromeda/fix"
|
|
9
|
+
require_relative "andromeda/check"
|
|
10
|
+
require_relative "andromeda/frontmatter"
|
|
11
|
+
require_relative "andromeda/schema"
|
|
12
|
+
require_relative "andromeda/slugger"
|
|
13
|
+
require_relative "andromeda/renderer"
|
|
14
|
+
require_relative "andromeda/components"
|
|
15
|
+
require_relative "andromeda/id"
|
|
16
|
+
require_relative "andromeda/relation"
|
|
17
|
+
require_relative "andromeda/registry"
|
|
18
|
+
require_relative "andromeda/loader"
|
|
19
|
+
require_relative "andromeda/entry"
|
|
20
|
+
require_relative "andromeda/store"
|
|
21
|
+
require_relative "andromeda/pipeline"
|
|
22
|
+
|
|
23
|
+
module Andromeda
|
|
24
|
+
# The native extension is built per Ruby minor version, so prefer the
|
|
25
|
+
# version-specific build when the packaged gem ships several of them.
|
|
26
|
+
begin
|
|
27
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
28
|
+
require_relative "andromeda_cms/#{Regexp.last_match(1)}/andromeda_cms"
|
|
29
|
+
rescue LoadError
|
|
30
|
+
require_relative "andromeda_cms/andromeda_cms"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Ruby wrapper around the native module the require above just defined;
|
|
34
|
+
# must load after it since it reopens Andromeda::Parser.
|
|
35
|
+
require_relative "andromeda/parser"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Loaded last so the Railtie can reference everything above it. The guard is
|
|
39
|
+
# on `Rails` itself, not `Rails::Railtie`: an application requires this gem
|
|
40
|
+
# before Rails has finished pulling in the railtie machinery.
|
|
41
|
+
require_relative "andromeda/railtie" if defined?(::Rails)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates everything a new content collection needs: an entry class
|
|
3
|
+
under app/models/content (or your configured entry_namespace), a
|
|
4
|
+
controller, index/show views, a route, and a starter content file with
|
|
5
|
+
frontmatter matching the declared attributes.
|
|
6
|
+
|
|
7
|
+
NAME is the collection as it appears under app/content/, and is used
|
|
8
|
+
as-is for the controller, views, route and content directory. The entry
|
|
9
|
+
class file/constant is the singular of NAME.
|
|
10
|
+
|
|
11
|
+
Field types: string (default) / date / datetime / boolean / integer /
|
|
12
|
+
array / image / reference. `field:reference:other_collection` names the
|
|
13
|
+
referenced collection explicitly; without it, the field name is
|
|
14
|
+
pluralized as a guess.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
rails generate andromeda:collection posts title:string pub_date:date draft:boolean
|
|
18
|
+
|
|
19
|
+
This creates:
|
|
20
|
+
app/models/content/post.rb
|
|
21
|
+
app/controllers/posts_controller.rb
|
|
22
|
+
app/views/posts/index.html.erb
|
|
23
|
+
app/views/posts/show.html.erb
|
|
24
|
+
app/content/posts/hello-world.md
|
|
25
|
+
config/routes.rb -- adds `resources :posts, only: %i[index show], param: :slug`
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "andromeda_cms"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Andromeda
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails g andromeda:collection NAME [field:type ...]` --
|
|
9
|
+
# everything a new collection needs: the entry class, a
|
|
10
|
+
# plain Rails controller/views, a route, and a starter content file.
|
|
11
|
+
#
|
|
12
|
+
# `NAME` names the collection exactly as it will appear under
|
|
13
|
+
# `app/content/` and is used as-is for the controller/views/route/
|
|
14
|
+
# content directory (the controller follows the URL, not the
|
|
15
|
+
# content namespace). Only the entry *class* is singularized, matching
|
|
16
|
+
# `app/models/content/post.rb` for a `posts` collection.
|
|
17
|
+
#
|
|
18
|
+
# All file content is built directly (rather than via `.tt` ERB
|
|
19
|
+
# templates) since every piece of it -- the namespace nesting, the
|
|
20
|
+
# attribute list, which attribute doubles as a title -- depends on
|
|
21
|
+
# generator input in a way a static template can't express cleanly.
|
|
22
|
+
class CollectionGenerator < Rails::Generators::NamedBase
|
|
23
|
+
# Deliberately named `fields`, not `attributes`: Rails::Generators::
|
|
24
|
+
# NamedBase auto-detects an `attributes` argument (`respond_to?
|
|
25
|
+
# (:attributes)`) and rewrites it into GeneratedAttribute objects
|
|
26
|
+
# using its own `field:type:index` syntax (`index` meaning a DB
|
|
27
|
+
# index), which collides with our `field:type:collection` syntax for
|
|
28
|
+
# :reference attributes.
|
|
29
|
+
argument :fields, type: :array, default: [], banner: "field:type field:type"
|
|
30
|
+
|
|
31
|
+
# Schema types (schema.rb's TYPES) that make sense to request from the
|
|
32
|
+
# command line; anything else -- or no `:type` at all -- defaults to
|
|
33
|
+
# :string, the same fallback Rails' own generators use.
|
|
34
|
+
TYPE_ALIASES = {
|
|
35
|
+
"string" => :string,
|
|
36
|
+
"date" => :date,
|
|
37
|
+
"datetime" => :datetime,
|
|
38
|
+
"boolean" => :boolean,
|
|
39
|
+
"bool" => :boolean,
|
|
40
|
+
"integer" => :integer,
|
|
41
|
+
"int" => :integer,
|
|
42
|
+
"array" => :array,
|
|
43
|
+
"image" => :image,
|
|
44
|
+
"reference" => :reference
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def create_model
|
|
48
|
+
create_file model_path, model_content
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_controller
|
|
52
|
+
create_file "app/controllers/#{collection_name}_controller.rb", controller_content
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def create_views
|
|
56
|
+
create_file "app/views/#{collection_name}/index.html.erb", index_view_content
|
|
57
|
+
create_file "app/views/#{collection_name}/show.html.erb", show_view_content
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Only appends the route when it is not already there, so running the
|
|
61
|
+
# generator twice (e.g. after adding a field) never duplicates it --
|
|
62
|
+
# Thor's own `route` action always appends unconditionally.
|
|
63
|
+
def add_route
|
|
64
|
+
routes_path = File.join(destination_root, "config/routes.rb")
|
|
65
|
+
|
|
66
|
+
if File.exist?(routes_path) && File.read(routes_path).include?(route_line)
|
|
67
|
+
say_status :identical, "config/routes.rb (route already present)", :blue
|
|
68
|
+
else
|
|
69
|
+
route route_line
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_starter_content
|
|
74
|
+
parsed_attributes.each do |attr|
|
|
75
|
+
next unless attr[:type] == :image
|
|
76
|
+
|
|
77
|
+
# A schema :image attribute is validated by checking the file
|
|
78
|
+
# actually exists (Schema#coerce_image) -- an empty placeholder
|
|
79
|
+
# satisfies that check without requiring a real asset.
|
|
80
|
+
create_file "app/content/#{collection_name}/#{attr[:name]}#{IMAGE_EXTENSION}", ""
|
|
81
|
+
end
|
|
82
|
+
create_file "app/content/#{collection_name}/hello-world.md", starter_content
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
IMAGE_EXTENSION = ".png"
|
|
88
|
+
|
|
89
|
+
# The collection name exactly as given, underscored -- this is what
|
|
90
|
+
# the route, controller, views and content directory all use.
|
|
91
|
+
def collection_name
|
|
92
|
+
@collection_name ||= name.underscore
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def singular_name
|
|
96
|
+
@singular_name ||= collection_name.singularize
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def namespace_modules
|
|
100
|
+
@namespace_modules ||= Andromeda.config.entry_namespace.split("::")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def class_basename
|
|
104
|
+
singular_name.camelize
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def full_class_name
|
|
108
|
+
(namespace_modules + [class_basename]).join("::")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def route_line
|
|
112
|
+
"resources :#{collection_name}, only: %i[index show], param: :slug"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parsed_attributes
|
|
116
|
+
@parsed_attributes ||= fields.map { |raw| parse_attribute(raw) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_attribute(raw)
|
|
120
|
+
field, type_str, extra = raw.split(":", 3)
|
|
121
|
+
type = TYPE_ALIASES.fetch(type_str.to_s, :string)
|
|
122
|
+
{ name: field.underscore, type: type, extra: extra }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def attribute_declaration(attr)
|
|
126
|
+
case attr[:type]
|
|
127
|
+
when :boolean
|
|
128
|
+
"attribute :#{attr[:name]}, :boolean, default: false"
|
|
129
|
+
when :array
|
|
130
|
+
"attribute :#{attr[:name]}, :array, of: :string, default: []"
|
|
131
|
+
when :reference
|
|
132
|
+
target = (attr[:extra] || attr[:name].pluralize).to_sym
|
|
133
|
+
"attribute :#{attr[:name]}, :reference, collection: :#{target}"
|
|
134
|
+
else
|
|
135
|
+
"attribute :#{attr[:name]}, :#{attr[:type]}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def model_path
|
|
140
|
+
File.join(Andromeda.config.entry_class_path, "#{singular_name}.rb")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def model_content
|
|
144
|
+
indent = " " * namespace_modules.size
|
|
145
|
+
lines = ["# frozen_string_literal: true", ""]
|
|
146
|
+
namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
|
|
147
|
+
lines << "#{indent}class #{class_basename} < Andromeda::Entry"
|
|
148
|
+
lines << "#{indent} collection :#{collection_name}, base: \"app/content/#{collection_name}\", " \
|
|
149
|
+
"pattern: \"**/*.{md,mdx}\""
|
|
150
|
+
unless parsed_attributes.empty?
|
|
151
|
+
lines << ""
|
|
152
|
+
parsed_attributes.each { |attr| lines << "#{indent} #{attribute_declaration(attr)}" }
|
|
153
|
+
end
|
|
154
|
+
lines << ""
|
|
155
|
+
lines << "#{indent} # scope :published, -> { where(draft: false) }"
|
|
156
|
+
lines << "#{indent}end"
|
|
157
|
+
namespace_modules.size.downto(1) { |i| lines << "#{" " * (i - 1)}end" }
|
|
158
|
+
"#{lines.join("\n")}\n"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def controller_content
|
|
162
|
+
order_attribute = parsed_attributes.find { |attr| %i[date datetime].include?(attr[:type]) }
|
|
163
|
+
listing = if order_attribute
|
|
164
|
+
"#{full_class_name}.all.order(#{order_attribute[:name]}: :desc)"
|
|
165
|
+
else
|
|
166
|
+
"#{full_class_name}.all"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
<<~RUBY
|
|
170
|
+
# frozen_string_literal: true
|
|
171
|
+
|
|
172
|
+
class #{collection_name.camelize}Controller < ApplicationController
|
|
173
|
+
def index
|
|
174
|
+
@entries = #{listing}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def show
|
|
178
|
+
@entry = #{full_class_name}.find(params[:slug])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
RUBY
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Best-effort attribute to display as an entry's title: the first
|
|
185
|
+
# declared :string attribute, or `id` when none was declared.
|
|
186
|
+
def title_method
|
|
187
|
+
title_attribute = parsed_attributes.find { |attr| attr[:type] == :string }
|
|
188
|
+
title_attribute ? title_attribute[:name] : "id"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def index_view_content
|
|
192
|
+
<<~ERB
|
|
193
|
+
<h1><%= "#{collection_name.humanize}" %></h1>
|
|
194
|
+
<ul>
|
|
195
|
+
<% @entries.each do |entry| %>
|
|
196
|
+
<li><%= link_to entry.#{title_method}, #{collection_name}_path(entry.id) %></li>
|
|
197
|
+
<% end %>
|
|
198
|
+
</ul>
|
|
199
|
+
ERB
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def show_view_content
|
|
203
|
+
<<~ERB
|
|
204
|
+
<article>
|
|
205
|
+
<h1><%= @entry.#{title_method} %></h1>
|
|
206
|
+
<%= andromeda_content @entry %>
|
|
207
|
+
</article>
|
|
208
|
+
ERB
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def starter_content
|
|
212
|
+
frontmatter_lines = parsed_attributes.filter_map { |attr| starter_frontmatter_line(attr) }
|
|
213
|
+
|
|
214
|
+
<<~MARKDOWN
|
|
215
|
+
---
|
|
216
|
+
#{frontmatter_lines.join("\n")}
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Getting started
|
|
220
|
+
|
|
221
|
+
Edit this file, or delete it and add your own content.
|
|
222
|
+
MARKDOWN
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def starter_frontmatter_line(attr)
|
|
226
|
+
case attr[:type]
|
|
227
|
+
when :string then "#{attr[:name]}: Hello World"
|
|
228
|
+
when :date then "#{attr[:name]}: #{Date.today.iso8601}"
|
|
229
|
+
when :datetime then "#{attr[:name]}: #{Time.now.utc.iso8601}"
|
|
230
|
+
when :boolean then "#{attr[:name]}: false"
|
|
231
|
+
when :integer then "#{attr[:name]}: 1"
|
|
232
|
+
when :array then "#{attr[:name]}: [example]"
|
|
233
|
+
when :image then "#{attr[:name]}: #{attr[:name]}#{IMAGE_EXTENSION}"
|
|
234
|
+
when :reference then "#{attr[:name]}: example"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates a partial for an MDX component under
|
|
3
|
+
app/views/content_components/, matching the naming convention
|
|
4
|
+
Andromeda::Components resolves tags with: `Callout` -> _callout.html.erb.
|
|
5
|
+
|
|
6
|
+
NAME must be a single CamelCase or underscored word (no namespaces, no
|
|
7
|
+
dots) -- MDX component tags are a single JSX identifier.
|
|
8
|
+
|
|
9
|
+
Each PROP becomes an optional local (`local_assigns[:prop]`); the tag's
|
|
10
|
+
children are always available as `content`.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
rails generate andromeda:component Callout type title
|
|
14
|
+
|
|
15
|
+
Creates app/views/content_components/_callout.html.erb.
|