andromeda_cms 0.1.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CONTRIBUTING.md +47 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +276 -0
  6. data/lib/andromeda/assets.rb +128 -0
  7. data/lib/andromeda/check.rb +71 -0
  8. data/lib/andromeda/components/errors.rb +97 -0
  9. data/lib/andromeda/components/import_scanner.rb +72 -0
  10. data/lib/andromeda/components/static_expression.rb +79 -0
  11. data/lib/andromeda/components.rb +348 -0
  12. data/lib/andromeda/configuration.rb +91 -0
  13. data/lib/andromeda/entry.rb +294 -0
  14. data/lib/andromeda/errors.rb +173 -0
  15. data/lib/andromeda/fix.rb +71 -0
  16. data/lib/andromeda/frontmatter.rb +327 -0
  17. data/lib/andromeda/helpers.rb +84 -0
  18. data/lib/andromeda/id.rb +58 -0
  19. data/lib/andromeda/loader.rb +96 -0
  20. data/lib/andromeda/parser.rb +75 -0
  21. data/lib/andromeda/pipeline.rb +296 -0
  22. data/lib/andromeda/railtie.rb +47 -0
  23. data/lib/andromeda/registry.rb +127 -0
  24. data/lib/andromeda/relation.rb +125 -0
  25. data/lib/andromeda/renderer/code_highlighter.rb +53 -0
  26. data/lib/andromeda/renderer/errors.rb +52 -0
  27. data/lib/andromeda/renderer/literal_expression.rb +201 -0
  28. data/lib/andromeda/renderer.rb +411 -0
  29. data/lib/andromeda/schema.rb +383 -0
  30. data/lib/andromeda/slugger.rb +68 -0
  31. data/lib/andromeda/store.rb +286 -0
  32. data/lib/andromeda/tasks/andromeda.rake +56 -0
  33. data/lib/andromeda/version.rb +5 -0
  34. data/lib/andromeda_cms/3.2/andromeda_cms.so +0 -0
  35. data/lib/andromeda_cms/3.3/andromeda_cms.so +0 -0
  36. data/lib/andromeda_cms/3.4/andromeda_cms.so +0 -0
  37. data/lib/andromeda_cms/4.0/andromeda_cms.so +0 -0
  38. data/lib/andromeda_cms.rb +41 -0
  39. data/lib/generators/andromeda/collection/USAGE +25 -0
  40. data/lib/generators/andromeda/collection/collection_generator.rb +239 -0
  41. data/lib/generators/andromeda/component/USAGE +15 -0
  42. data/lib/generators/andromeda/component/component_generator.rb +75 -0
  43. data/lib/generators/andromeda/import_astro/USAGE +21 -0
  44. data/lib/generators/andromeda/import_astro/astro_schema_json.rb +80 -0
  45. data/lib/generators/andromeda/import_astro/balanced_scanner.rb +168 -0
  46. data/lib/generators/andromeda/import_astro/content_config_converter.rb +232 -0
  47. data/lib/generators/andromeda/import_astro/import_astro_generator.rb +313 -0
  48. data/lib/generators/andromeda/import_astro/mdx_content_scanner.rb +91 -0
  49. data/lib/generators/andromeda/import_astro/mdx_import_rewriter.rb +91 -0
  50. data/lib/generators/andromeda/install/USAGE +11 -0
  51. data/lib/generators/andromeda/install/install_generator.rb +77 -0
  52. data/lib/generators/andromeda/install/templates/initializer.rb +30 -0
  53. metadata +163 -0
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "andromeda_cms"
4
+ require "active_support/core_ext/string/inflections"
5
+ require_relative "content_config_converter"
6
+ require_relative "astro_schema_json"
7
+ require_relative "mdx_import_rewriter"
8
+ require_relative "mdx_content_scanner"
9
+ require_relative "../component/component_generator"
10
+
11
+ module Andromeda
12
+ module Generators
13
+ # `rails g andromeda:import_astro PATH_TO_ASTRO_PROJECT [--dry-run]`
14
+ # -- one-shot migration of an existing Astro
15
+ # project's content into a host app already set up with
16
+ # `andromeda:install`:
17
+ #
18
+ # 1. Copy every collection's content directory into `app/content/
19
+ # <collection>/`, and `src/assets` into `app/assets/`, rewriting
20
+ # only frontmatter keys and MDX
21
+ # import statements -- otherwise byte-for-byte.
22
+ # 2. Convert `src/content.config.ts` into one `Andromeda::Entry`
23
+ # subclass per collection, mapping the Zod shapes
24
+ # Astro supports; anything else becomes a commented-out line with a
25
+ # TODO rather than a silent omission.
26
+ # 3. Generate a partial stub for every MDX component tag that has no
27
+ # partial yet.
28
+ # 4. Print a migration report: what was copied/rewritten/generated,
29
+ # and everything that still needs a human.
30
+ #
31
+ # `--dry-run` runs the exact same analysis and prints the same report,
32
+ # but writes nothing -- implemented as Thor's own `--pretend`, which
33
+ # every `create_file`/`copy_file` call below already respects, rather
34
+ # than a second no-op code path to keep in sync with the real one.
35
+ class ImportAstroGenerator < Rails::Generators::Base
36
+ argument :astro_path, type: :string, banner: "PATH_TO_ASTRO_PROJECT"
37
+ class_option :dry_run, type: :boolean, default: false,
38
+ desc: "Print the migration report without writing any files"
39
+
40
+ def import
41
+ # Thor's own options Hash is frozen by the time a command runs, so
42
+ # `--dry-run` is translated into Thor's built-in `--pretend`
43
+ # (which every `create_file`/`copy_file` call below already
44
+ # respects) via a fresh, unfrozen Hash rather than an in-place
45
+ # mutation.
46
+ self.options = options.merge(pretend: true) if options[:dry_run]
47
+
48
+ @report = {
49
+ copied: [], frontmatter_renames: [], import_rewrites: [],
50
+ models: [], stubs: [], unsupported_schema: [], unsupported_expressions: [],
51
+ schema_json_used: [], notes: []
52
+ }
53
+
54
+ load_collections
55
+ copy_collections
56
+ copy_assets
57
+ generate_entry_classes
58
+ generate_component_stubs
59
+ print_report
60
+ end
61
+
62
+ private
63
+
64
+ def astro_root
65
+ @astro_root ||= File.expand_path(astro_path)
66
+ end
67
+
68
+ def config_path
69
+ File.join(astro_root, "src/content.config.ts")
70
+ end
71
+
72
+ # @return [Array<ImportAstro::ContentConfigConverter::Collection>]
73
+ def load_collections
74
+ @collections =
75
+ if File.file?(config_path)
76
+ ImportAstro::ContentConfigConverter.parse(File.read(config_path), astro_root: astro_root)
77
+ else
78
+ @report[:notes] << "no src/content.config.ts found -- falling back to every directory under " \
79
+ "src/content/ with no schema"
80
+ fallback_collections
81
+ end
82
+
83
+ @collections.each { |collection| refine_with_json_schema!(collection) }
84
+ end
85
+
86
+ # The fallback clause: every top-level directory under
87
+ # `src/content/` becomes a schema-less collection so the content
88
+ # itself is still migrated even without a `content.config.ts`.
89
+ def fallback_collections
90
+ base = File.join(astro_root, "src/content")
91
+ return [] unless Dir.exist?(base)
92
+
93
+ Dir.children(base).select { |name| File.directory?(File.join(base, name)) }.sort.map do |name|
94
+ ImportAstro::ContentConfigConverter::Collection.new(
95
+ name: name, base: File.join(base, name), pattern: "**/*.{md,mdx}", attributes: []
96
+ )
97
+ end
98
+ end
99
+
100
+ # Prefers the generated JSON Schema -- see astro_schema_json.rb
101
+ # for exactly what it can and cannot correct.
102
+ def refine_with_json_schema!(collection)
103
+ schema_json = ImportAstro::AstroSchemaJson.read(astro_root, collection.name)
104
+ return unless schema_json
105
+
106
+ @report[:schema_json_used] << collection.name
107
+ collection.attributes.each do |attr|
108
+ required = ImportAstro::AstroSchemaJson.required?(schema_json, attr.name)
109
+ attr.required = required unless required.nil?
110
+
111
+ next unless attr.ruby_type == :float
112
+
113
+ precision = ImportAstro::AstroSchemaJson.numeric_precision(schema_json, attr.name)
114
+ attr.ruby_type = precision if precision
115
+ end
116
+ end
117
+
118
+ def copy_collections
119
+ @collections.each do |collection|
120
+ next unless Dir.exist?(collection.base)
121
+
122
+ Dir.glob(File.join(collection.base, "**/*")).sort.each do |src|
123
+ next unless File.file?(src)
124
+
125
+ relative = src.delete_prefix("#{collection.base}/")
126
+ dest = "app/content/#{collection.name}/#{relative}"
127
+ copy_content_file(src, dest)
128
+ end
129
+ end
130
+ end
131
+
132
+ # Byte-for-byte except for the two rewrites below, and
133
+ # only for `.md`/`.mdx` files -- a sibling image living inside a
134
+ # directory-form entry (`my-post/cover.png`) is copied untouched.
135
+ def copy_content_file(src, dest)
136
+ extension = File.extname(src).downcase
137
+ unless %w[.md .mdx].include?(extension)
138
+ create_file dest, File.binread(src)
139
+ @report[:copied] << dest
140
+ return
141
+ end
142
+
143
+ source = File.read(src)
144
+ fixed, renames = Andromeda::Fix.fix_source(source)
145
+ renames.each { |from, to| @report[:frontmatter_renames] << "#{dest}: #{from} -> #{to}" }
146
+
147
+ if extension == ".mdx"
148
+ fixed, rewrites = ImportAstro::MdxImportRewriter.rewrite(fixed)
149
+ rewrites.each { |r| @report[:import_rewrites] << "#{dest}: #{r[:from]} -> #{r[:to]}" }
150
+ end
151
+
152
+ create_file dest, fixed
153
+ @report[:copied] << dest
154
+
155
+ scan_mdx_content(fixed, dest) if extension == ".mdx"
156
+ end
157
+
158
+ def scan_mdx_content(source, dest)
159
+ (@component_usages ||= {}).merge!(
160
+ ImportAstro::MdxContentScanner.component_usages(source, path: dest)
161
+ ) { |_name, existing, incoming| existing.props |= incoming.props; existing }
162
+
163
+ @report[:unsupported_expressions].concat(
164
+ ImportAstro::MdxContentScanner.unsupported_expressions(source, path: dest)
165
+ )
166
+ end
167
+
168
+ def copy_assets
169
+ assets_root = File.join(astro_root, "src/assets")
170
+ return unless Dir.exist?(assets_root)
171
+
172
+ Dir.glob(File.join(assets_root, "**/*")).sort.each do |src|
173
+ next unless File.file?(src)
174
+
175
+ relative = src.delete_prefix("#{assets_root}/")
176
+ dest = "app/assets/#{relative}"
177
+ create_file dest, File.binread(src)
178
+ @report[:copied] << dest
179
+ end
180
+ end
181
+
182
+ def generate_entry_classes
183
+ @collections.each do |collection|
184
+ path = model_path(collection)
185
+ create_file path, model_content(collection)
186
+ @report[:models] << path
187
+
188
+ collection.attributes.select(&:unsupported?).each do |attr|
189
+ @report[:unsupported_schema] << "#{collection.name}.#{attr.name} (content.config.ts: #{attr.source})"
190
+ end
191
+ end
192
+ end
193
+
194
+ def generate_component_stubs
195
+ return unless @component_usages
196
+
197
+ @component_usages.each_value do |usage|
198
+ next if partial_exists?(usage.name)
199
+
200
+ # Reuses the real `andromeda:component` generator (rather than
201
+ # duplicating its stub-content logic here) via Thor's own
202
+ # `invoke`, which shares this generator's `destination_root`
203
+ # automatically. `pretend:` is passed through explicitly --
204
+ # `invoke` only inherits a fresh invocation's *original* options
205
+ # (captured at construction time), not this instance's later
206
+ # `self.options = options.merge(pretend: true)` translation of
207
+ # `--dry-run` above.
208
+ invoke Andromeda::Generators::ComponentGenerator, [usage.name, *usage.props], pretend: options[:dry_run]
209
+ @report[:stubs] << component_partial_path(usage.name)
210
+ end
211
+ end
212
+
213
+ def partial_exists?(name)
214
+ File.exist?(File.join(destination_root, component_partial_path(name)))
215
+ end
216
+
217
+ def component_partial_path(name)
218
+ "#{Andromeda.config.components_path}/_#{Andromeda::Components.underscore(name)}.html.erb"
219
+ end
220
+
221
+ def namespace_modules
222
+ @namespace_modules ||= Andromeda.config.entry_namespace.split("::")
223
+ end
224
+
225
+ def model_path(collection)
226
+ File.join(Andromeda.config.entry_class_path, "#{collection.name.to_s.singularize}.rb")
227
+ end
228
+
229
+ def model_content(collection)
230
+ indent = " " * namespace_modules.size
231
+ lines = ["# frozen_string_literal: true", ""]
232
+ namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
233
+ lines << "#{indent}class #{collection.name.to_s.singularize.camelize} < Andromeda::Entry"
234
+ lines << "#{indent} collection :#{collection.name}, base: \"app/content/#{collection.name}\", " \
235
+ "pattern: \"#{collection.pattern}\""
236
+
237
+ unless collection.attributes.empty?
238
+ lines << ""
239
+ collection.attributes.each { |attr| attribute_lines(attr).each { |line| lines << "#{indent} #{line}" } }
240
+ end
241
+
242
+ lines << "#{indent}end"
243
+ namespace_modules.size.downto(1) { |i| lines << "#{" " * (i - 1)}end" }
244
+ "#{lines.join("\n")}\n"
245
+ end
246
+
247
+ # @return [Array<String>] one or more lines for this attribute: a
248
+ # real `attribute` declaration, or -- when the Zod expression could
249
+ # not be mapped -- a `# TODO` naming the original source plus a
250
+ # commented-out best guess, never a silent omission.
251
+ def attribute_lines(attr)
252
+ return unsupported_attribute_lines(attr) if attr.unsupported?
253
+
254
+ lines = []
255
+ lines << "# NOTE: #{attr.note}" if attr.note
256
+ lines << "attribute :#{snake_name(attr)}, :#{attr.ruby_type}#{attribute_modifiers(attr)}"
257
+ lines
258
+ end
259
+
260
+ def unsupported_attribute_lines(attr)
261
+ [
262
+ "# TODO: unsupported schema for `#{attr.name}` in content.config.ts (#{attr.source}) -- " \
263
+ "add the equivalent `attribute` by hand.",
264
+ "# attribute :#{snake_name(attr)}, :string"
265
+ ]
266
+ end
267
+
268
+ # The generated class's `attribute` calls must use the same
269
+ # snake_case names `Andromeda::Fix` gives the frontmatter keys
270
+ # themselves -- `pubDate` in `content.config.ts` has to line
271
+ # up with `pub_date:` in the copied Markdown, or the schema would
272
+ # look for a key that no longer exists in the file.
273
+ def snake_name(attr)
274
+ Andromeda::Fix.snake_case(attr.name)
275
+ end
276
+
277
+ def attribute_modifiers(attr)
278
+ parts = []
279
+ parts << "of: :#{attr.of}" if attr.ruby_type == :array && attr.of
280
+ parts << "values: #{attr.values.inspect}" if attr.ruby_type == :enum
281
+ parts << "collection: :#{attr.collection}" if attr.ruby_type == :reference
282
+ parts << "required: true" if attr.required && attr.default.nil?
283
+ parts << "default: #{attr.default}" if attr.default
284
+ parts.empty? ? "" : ", #{parts.join(", ")}"
285
+ end
286
+
287
+ def print_report
288
+ say ""
289
+ say(options[:dry_run] ? "Astro import (dry run -- nothing was written):" : "Astro import complete:")
290
+ say ""
291
+ report_section "Copied", @report[:copied]
292
+ report_section "Frontmatter keys renamed to snake_case", @report[:frontmatter_renames]
293
+ report_section "MDX import statements rewritten", @report[:import_rewrites]
294
+ report_section "Entry classes generated", @report[:models]
295
+ report_section "Component stubs generated", @report[:stubs]
296
+ report_section "Collections that used Astro's generated JSON Schema", @report[:schema_json_used]
297
+ say ""
298
+ say "Needs a human:"
299
+ report_section " Unmapped schema fields", @report[:unsupported_schema], indent: " "
300
+ report_section " Expressions the renderer cannot evaluate", @report[:unsupported_expressions], indent: " "
301
+ report_section " Notes", @report[:notes], indent: " "
302
+ end
303
+
304
+ def report_section(title, items, indent: " ")
305
+ return if items.empty?
306
+
307
+ say "#{title}:"
308
+ items.each { |item| say "#{indent}#{item}" }
309
+ say ""
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../andromeda/components/static_expression"
4
+
5
+ module Andromeda
6
+ module Generators
7
+ module ImportAstro
8
+ # Parses an already-copied `.mdx` file with the real Sätteri-backed
9
+ # `Andromeda::Parser` (rather than a second regex pass) to find:
10
+ #
11
+ # - every JSX component tag actually used, with the prop names seen on
12
+ # it (for generating partial stubs), and
13
+ # - every `{...}` expression that `Andromeda::Components::
14
+ # StaticExpression` -- the same evaluator the real renderer uses --
15
+ # cannot evaluate, for the migration report's "expressions the
16
+ # renderer cannot evaluate" section.
17
+ #
18
+ # This only inspects the tree; it never renders anything, so it needs
19
+ # no view layer and works the same whether or not the host app has
20
+ # partials yet.
21
+ module MdxContentScanner
22
+ ComponentUsage = Struct.new(:name, :props, keyword_init: true)
23
+
24
+ module_function
25
+
26
+ # @param source [String] MDX source, already import-rewritten (the
27
+ # rewrite does not change tag usage, so scan order does not
28
+ # matter).
29
+ # @param path [String] for error messages only.
30
+ # @return [Hash{String => ComponentUsage}] tag name -> usage,
31
+ # `Fragment` excluded (never a real component).
32
+ def component_usages(source, path:)
33
+ tree = Andromeda::Parser.parse(source, mdx: true, path: path)
34
+ usages = {}
35
+
36
+ walk(tree) do |node|
37
+ next unless %w[mdxJsxFlowElement mdxJsxTextElement].include?(node[:type])
38
+
39
+ name = node[:name]
40
+ next if name.nil? || name == "Fragment" || name.include?(".")
41
+
42
+ usage = (usages[name] ||= ComponentUsage.new(name: name, props: []))
43
+ (node[:attributes] || []).each do |attribute|
44
+ next unless attribute[:type] == "mdxJsxAttribute"
45
+
46
+ usage.props << attribute[:name] unless usage.props.include?(attribute[:name])
47
+ end
48
+ end
49
+
50
+ usages
51
+ rescue Andromeda::SyntaxError, Andromeda::ParserError
52
+ # Not this generator's job to diagnose a broken source file --
53
+ # `andromeda:check`/the renderer will raise the same error again,
54
+ # with the same message, once the file is actually rendered. The
55
+ # migration report calls this out instead of failing the import.
56
+ {}
57
+ end
58
+
59
+ # @return [Array<String>] one line per expression the renderer would
60
+ # raise on, `"path:line: {source}"`.
61
+ def unsupported_expressions(source, path:)
62
+ tree = Andromeda::Parser.parse(source, mdx: true, path: path)
63
+ problems = []
64
+
65
+ walk(tree) do |node|
66
+ next unless %w[mdxFlowExpression mdxTextExpression].include?(node[:type])
67
+
68
+ begin
69
+ Andromeda::Components::StaticExpression.evaluate(node[:value].to_s, frontmatter: {})
70
+ rescue Andromeda::Components::StaticExpression::UnsupportedExpressionError
71
+ line = node.dig(:position, :start, :line)
72
+ problems << "#{path}:#{line}: {#{node[:value]}}"
73
+ end
74
+ end
75
+
76
+ problems
77
+ rescue Andromeda::SyntaxError, Andromeda::ParserError
78
+ []
79
+ end
80
+
81
+ def walk(node, &block)
82
+ return unless node.is_a?(Hash)
83
+
84
+ yield node if node[:type]
85
+ (node[:children] || []).each { |child| walk(child, &block) }
86
+ end
87
+ private_class_method :walk
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Andromeda
4
+ module Generators
5
+ module ImportAstro
6
+ # Rewrites MDX import statements to the partial-path form:
7
+ # `import Callout from '../../components/Callout.astro';`
8
+ # becomes `import Callout from 'content_components/callout';`. Only
9
+ # component imports are rewritten -- an image import
10
+ # (`import cover from './cover.png'`) is left alone, since `cover` is
11
+ # bound to an asset, not resolved as a JSX tag (image imports are a
12
+ # separate, deferred concern from this rewrite).
13
+ #
14
+ # A regex pass over the raw text, matching
15
+ # `Andromeda::Components::ImportScanner`'s own reasoning: MDX imports
16
+ # are a small, regular subset of ES module syntax, and rewriting only
17
+ # ever needs to look at one import statement at a time.
18
+ module MdxImportRewriter
19
+ DEFAULT_IMPORT_RE = /^import\s+([A-Za-z_$][\w$]*)\s+from\s+(['"])(.*?)\2\s*;?[ \t]*$/
20
+ NAMED_IMPORT_RE = /^import\s*\{([^}]*)\}\s*from\s+(['"])(.*?)\2\s*;?[ \t]*$/
21
+ NAMED_BINDING_RE = /\A([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\z/
22
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .avif].freeze
23
+
24
+ module_function
25
+
26
+ # @param source [String] a whole `.mdx` file's contents.
27
+ # @return [Array(String, Array<Hash>)] the rewritten source and a
28
+ # list of `{from:, to:}` rewrites applied, for the migration
29
+ # report.
30
+ def rewrite(source)
31
+ rewrites = []
32
+
33
+ rewritten = source.gsub(NAMED_IMPORT_RE) do |whole|
34
+ rewrite_named(whole, Regexp.last_match(1), Regexp.last_match(3), rewrites)
35
+ end
36
+
37
+ rewritten = rewritten.gsub(DEFAULT_IMPORT_RE) do |whole|
38
+ rewrite_default(whole, Regexp.last_match(1), Regexp.last_match(3), rewrites)
39
+ end
40
+
41
+ [rewritten, rewrites]
42
+ end
43
+
44
+ def rewrite_default(whole, name, path, rewrites)
45
+ return whole unless component_name?(name) && !image_path?(path)
46
+
47
+ replacement = "import #{name} from '#{partial_path(name)}';"
48
+ rewrites << { from: whole.strip, to: replacement }
49
+ replacement
50
+ end
51
+
52
+ # A named import list is rewritten only when every binding looks
53
+ # like a component (capitalized) -- one import path cannot honestly
54
+ # become several different partial paths, and a mixed list
55
+ # (components alongside a lowercase helper) is rare enough in
56
+ # practice that leaving it untouched (and letting naming-convention
57
+ # resolution handle the components at render time) is safer than
58
+ # guessing which names to split out.
59
+ def rewrite_named(whole, bindings_source, _path, rewrites)
60
+ bindings = bindings_source.split(",").filter_map { |b| b.strip unless b.strip.empty? }
61
+ locals = bindings.map { |binding| NAMED_BINDING_RE.match(binding) }
62
+ return whole if locals.any?(&:nil?)
63
+
64
+ local_names = locals.map { |m| m[2] || m[1] }
65
+ return whole unless local_names.all? { |name| component_name?(name) }
66
+
67
+ replacement = local_names.map { |name| "import #{name} from '#{partial_path(name)}';" }.join("\n")
68
+ rewrites << { from: whole.strip, to: replacement }
69
+ replacement
70
+ end
71
+
72
+ def component_name?(name)
73
+ name.match?(/\A[A-Z]/)
74
+ end
75
+
76
+ def image_path?(path)
77
+ IMAGE_EXTENSIONS.include?(File.extname(path).downcase)
78
+ end
79
+
80
+ # @return [String] `content_components/<underscored name>`, matching
81
+ # the naming-convention `Andromeda::Components` already resolves
82
+ # tags with, minus the `app/views/` prefix a partial
83
+ # path never includes.
84
+ def partial_path(name)
85
+ base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
86
+ "#{base}/#{Andromeda::Components.underscore(name)}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,11 @@
1
+ Description:
2
+ Sets up a host application for Andromeda: creates app/content/ and
3
+ app/views/content_components/, writes a commented config/initializers/
4
+ andromeda.rb, and appends the paths Andromeda writes at build time
5
+ (/.andromeda/ and /app/assets/builds/andromeda/) to .gitignore and
6
+ .dockerignore.
7
+
8
+ Safe to run more than once: it never duplicates a line it already added.
9
+
10
+ Example:
11
+ rails generate andromeda:install
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "andromeda_cms"
4
+
5
+ module Andromeda
6
+ module Generators
7
+ # `rails g andromeda:install` -- one-time setup for a host
8
+ # application: the content directory, the component partials directory,
9
+ # a commented initializer, and the two ignore files expect
10
+ # `.andromeda/` (the conversion cache) and the copied-image build
11
+ # directory to be excluded from.
12
+ #
13
+ # Every step here is safe to run twice: directories/files Thor already
14
+ # knows about follow its normal conflict handling, and the ignore-file
15
+ # lines are only appended when they are not already present.
16
+ class InstallGenerator < Rails::Generators::Base
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ IGNORE_LINES = ["/.andromeda/", "/app/assets/builds/andromeda/"].freeze
20
+
21
+ def create_content_directory
22
+ empty_directory "app/content"
23
+ create_file "app/content/.keep"
24
+ end
25
+
26
+ def create_components_directory
27
+ path = Andromeda.config.components_path
28
+ empty_directory path
29
+ create_file "#{path}/.keep"
30
+ end
31
+
32
+ def create_initializer
33
+ copy_file "initializer.rb", "config/initializers/andromeda.rb"
34
+ end
35
+
36
+ def update_ignore_files
37
+ [".gitignore", ".dockerignore"].each { |filename| append_ignore_lines(filename) }
38
+ end
39
+
40
+ def show_next_steps
41
+ say ""
42
+ say "Andromeda is set up. Next steps:"
43
+ say ""
44
+ say " 1. Generate a collection:"
45
+ say " rails generate andromeda:collection posts title:string pub_date:date"
46
+ say ""
47
+ say " 2. Or import existing content from an Astro project:"
48
+ say " rails generate andromeda:import_astro path/to/astro-project"
49
+ say ""
50
+ end
51
+
52
+ private
53
+
54
+ # Appends whichever of IGNORE_LINES aren't already in `filename`, or
55
+ # explains why nothing happened when the file doesn't exist -- an
56
+ # app without Docker, for instance, has no .dockerignore and should
57
+ # not get one invented for it.
58
+ def append_ignore_lines(filename)
59
+ path = File.join(destination_root, filename)
60
+
61
+ unless File.exist?(path)
62
+ say_status :skip, "#{filename} not found -- if you add one later, also add: #{IGNORE_LINES.join(", ")}", :yellow
63
+ return
64
+ end
65
+
66
+ existing_lines = File.readlines(path, chomp: true)
67
+ missing = IGNORE_LINES.reject { |line| existing_lines.include?(line) }
68
+ if missing.empty?
69
+ say_status :identical, filename, :blue
70
+ return
71
+ end
72
+
73
+ append_to_file filename, "#{missing.join("\n")}\n"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Uncomment and edit any of these to override Andromeda's defaults -- see
4
+ # Andromeda::Configuration for the full description of each setting.
5
+ Andromeda.configure do |config|
6
+ # Where content files live, relative to the application root.
7
+ # config.content_path = "app/content"
8
+
9
+ # Namespace of the entry classes -- `Content::Post` lives at
10
+ # app/models/content/post.rb by default. Change this if your app already
11
+ # defines a top-level `Content`.
12
+ # config.entry_namespace = "Content"
13
+
14
+ # Directory holding the partials MDX components resolve to.
15
+ # config.components_path = "app/views/content_components"
16
+
17
+ # Where `andromeda:build` writes converted content. Hidden and ignored by
18
+ # git and Docker, like Astro's own .astro/.
19
+ # config.build_path = ".andromeda"
20
+
21
+ # Rouge theme used for code blocks; the default mirrors the Shiki theme
22
+ # Astro ships with.
23
+ # config.highlight_theme = "github.dark"
24
+
25
+ # :development (convert on demand, checking each source file's digest) or
26
+ # :production (read-only; raises Andromeda::BuildMissing if
27
+ # `andromeda:build` never ran). The Railtie sets this from Rails.env at
28
+ # boot, so overriding it here is rarely needed.
29
+ # config.mode = :development
30
+ end