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.
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.bundle +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.bundle +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.bundle +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.bundle +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,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")
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ VERSION = "0.1.0"
5
+ end
@@ -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.